diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index e650d7af6a..467e4759b8 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -12,11 +12,27 @@ on:
paths:
- 'android/**'
- '.github/workflows/android.yml'
+ # The twin-drift guards read the SWIFT side's fixtures on purpose — DecoderOracleTest compares
+ # against Packages/.../decoder_oracle.json to catch a one-sided edit, and the schema/protocol
+ # oracles work the same way. Without these, editing the Swift copy alone leaves the very test
+ # written to catch that from ever running. Keep this list in step with what the tests read.
+ - 'Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/**'
+ - 'Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/**'
+ - 'Packages/WhoopStore/Tests/WhoopStoreTests/Resources/**'
+ - 'Strand/Resources/Localizable.xcstrings'
push:
branches: [main]
paths:
- 'android/**'
- '.github/workflows/android.yml'
+ # The twin-drift guards read the SWIFT side's fixtures on purpose — DecoderOracleTest compares
+ # against Packages/.../decoder_oracle.json to catch a one-sided edit, and the schema/protocol
+ # oracles work the same way. Without these, editing the Swift copy alone leaves the very test
+ # written to catch that from ever running. Keep this list in step with what the tests read.
+ - 'Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/**'
+ - 'Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/**'
+ - 'Packages/WhoopStore/Tests/WhoopStoreTests/Resources/**'
+ - 'Strand/Resources/Localizable.xcstrings'
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml
index 5059dab03f..5778d568d5 100644
--- a/.github/workflows/app-build.yml
+++ b/.github/workflows/app-build.yml
@@ -13,6 +13,18 @@ on:
branches: [main]
paths:
- 'Strand/**'
+ # The app-target TESTS, not just the sources they cover. The `Test Strand` step below runs
+ # StrandTests, but a PR touching only those tests matched nothing here, so it built and ran
+ # nothing: one that failed to compile, or simply failed, merged unverified and went red on the
+ # next unrelated PR that happened to touch `Strand/**`, mis-attributing the breakage to whoever
+ # that was.
+ #
+ # This is the only test directory that needs naming, and the reason is layout rather than
+ # oversight: Android's tests sit under `android/**` and the package suites under `Packages/**`,
+ # both already inside their subject's glob, while StrandTests is a SIBLING of `Strand/` instead
+ # of part of it. `project.yml` does not cover it either — the target takes
+ # `sources: [StrandTests]`, a directory, so adding a test file changes no other tracked path.
+ - 'StrandTests/**'
- 'StrandiOS/**'
- 'StrandiOSShared/**'
- 'StrandiOSWidgets/**'
diff --git a/.github/workflows/swift-packages.yml b/.github/workflows/swift-packages.yml
index c46bb25628..cb46672dcf 100644
--- a/.github/workflows/swift-packages.yml
+++ b/.github/workflows/swift-packages.yml
@@ -16,6 +16,11 @@ on:
- 'Tools/SleepPSG/**'
- 'Tools/Backfill/**'
- '.github/workflows/swift-packages.yml'
+ # Mirror of the note in android.yml: package tests read the ANDROID oracles to catch a one-sided
+ # edit, and one reads Strand/Liquid/LiquidCore.swift. Editing only that side would otherwise skip
+ # the guard written for exactly that case. Keep in step with what the tests read.
+ - 'android/app/src/test/resources/**'
+ - 'Strand/Liquid/LiquidCore.swift'
push:
branches: [main]
paths:
@@ -24,6 +29,11 @@ on:
- 'Tools/SleepPSG/**'
- 'Tools/Backfill/**'
- '.github/workflows/swift-packages.yml'
+ # Mirror of the note in android.yml: package tests read the ANDROID oracles to catch a one-sided
+ # edit, and one reads Strand/Liquid/LiquidCore.swift. Editing only that side would otherwise skip
+ # the guard written for exactly that case. Keep in step with what the tests read.
+ - 'android/app/src/test/resources/**'
+ - 'Strand/Liquid/LiquidCore.swift'
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml
index fc18f4429a..114bfde183 100644
--- a/.github/workflows/tools-python.yml
+++ b/.github/workflows/tools-python.yml
@@ -5,22 +5,35 @@
# one of them only ever executed when a contributor remembered to, which meant a decoder could regress and
# the suite that would have caught it stayed green by never being run.
#
-# Cheap to run: pure stdlib `unittest`, no dependencies, no fixtures to fetch, ubuntu rather than macOS.
-# Path-filtered so it only fires when the suites it runs change.
+# Cheap to run: pure stdlib `unittest`, no dependencies, no fixtures to fetch, ubuntu rather than macOS —
+# about 25 seconds end to end.
+#
+# NOT path-filtered, and that is deliberate. It used to fire only on `Tools/**.py`, on the premise that a
+# suite can only break when its own source changes. That is false for `test_home_i18n.py`, which asserts
+# on the CONTENTS of app and package files — AppModel.swift, the xcstrings catalogues, ReadinessEngine,
+# DayNavBar. A Swift-only commit broke it (#1671 moved a sign and a hardcoded °C out of a localized
+# literal) and the filter meant this job never ran, so `main` sat red until a contributor touching
+# `Tools/` for something unrelated tripped over it in their own PR (#1691).
+#
+# Widening the filter to name those paths would just re-create the trap one edit later: the list is
+# whatever the assertions happen to read today. Twenty-five seconds on every PR is cheaper than a gate
+# that is green because it did not run.
name: Tools Python CI
on:
+ # Path-filtered like android.yml / swift-packages.yml / app-build.yml. Without this the workflow ran on
+ # EVERY pull request and every push to main, including the many that touch no Python at all - and with a
+ # Windows runner now in it, billed at twice a Linux minute, that is a real cost for a job whose inputs
+ # cannot have changed. The workflow file itself is included so a change to the job still tests itself.
pull_request:
branches: [main]
paths:
- - 'Tools/linux-capture/**'
- - 'Tools/*.py'
+ - 'Tools/**'
- '.github/workflows/tools-python.yml'
push:
branches: [main]
paths:
- - 'Tools/linux-capture/**'
- - 'Tools/*.py'
+ - 'Tools/**'
- '.github/workflows/tools-python.yml'
workflow_dispatch:
@@ -76,3 +89,36 @@ jobs:
exit 1
fi
working-directory: Tools
+
+ windows-capture:
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ # Keep Windows locale defaults and exercise a strict legacy console, including subprocesses.
+ # UTF-8 mode would hide implicit file encodings; replacement output would hide console bugs.
+ - name: Run Tools/linux-capture tests with legacy Windows encodings
+ working-directory: Tools/linux-capture
+ env:
+ PYTHONUTF8: '0'
+ PYTHONIOENCODING: 'cp1252:strict'
+ PYTHONLEGACYWINDOWSSTDIO: '1'
+ shell: python
+ run: |
+ import sys
+ import unittest
+
+ suite = unittest.defaultTestLoader.discover('.', pattern='test_*.py')
+ count = suite.countTestCases()
+ print(f'collected {count} tests', flush=True)
+ result = unittest.TextTestRunner(verbosity=2).run(suite)
+ if count < 200 or result.testsRun < 200:
+ print('::error::expected at least 200 tests; discovery or execution is incomplete')
+ sys.exit(1)
+ sys.exit(0 if result.wasSuccessful() else 1)
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index dbd6f5563c..a84b289370 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -57,11 +57,15 @@ This is long-standing practice, not a one-off. Worked examples already in the tr
field order, widths and enum values for `FilteredLabradorPacket` / `RawLabradorPacket`, plus the
`{revision, arg, padding}` command payload shape, re-derived from static analysis of the vendor's iOS
client. The four command NUMBERS were already in our own `CommandNumber` table from the whoomp/goose
- work above. Everything ships as an **unvalidated candidate**: no strap has yet been asked whether it
- honours these commands, the decode backs no metric, and the fields nobody can attest (the wrist enum's
- raw values, the progress "timed out" sentinel, the ECG sample unit, the packet type byte) are carried
- raw rather than named. The on-strap rhythm classifier's verdict is decoded as a byte and is never
- presented as a finding — NOOP is not a medical device.
+ work above. Most of it still ships as an **unvalidated candidate**: the decode backs no metric, and the
+ fields nobody can attest (the wrist enum's raw values, the progress "timed out" sentinel, the ECG
+ sample unit, the packet type byte) are carried raw rather than named. **One exception is now attested
+ rather than inferred:** `ControlSignal`'s raw values were also read off the client's enum order, and a
+ WHOOP MG (`WS50_r00`, fw `50.39.1.0`) contradicts that reading — `1` stops generation, `2` starts it,
+ `0` is refused. The enum now carries the hardware values with the device and firmware named. A
+ hardware-observed value is strictly stronger than the inference it replaces, but it is one device.
+ The on-strap rhythm classifier's verdict is decoded as a byte and is never presented as a finding —
+ NOOP is not a medical device.
And the line held from the other side:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f939edfde5..7bd0c5740d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,66 @@ approximate; downloads are on the [Releases](https://github.com/NoopApp/noop/rel
---
+## 11.1.0: A clock you choose, sleep without motion, and logs that report instead of assuming (all platforms)
+
+**Choose how you read times**
+
+- **Clock format: System / 12-hour / 24-hour (#1821).** Times were decided by your device's *region*, not
+ by you. On Apple that is deliberate — it preserves regional date order and clock style — but building a
+ locale from a region identifier discards the explicit **24-Hour Time** switch in iOS Settings, so
+ someone in a 24-hour country who preferred a 12-hour clock had no way to ask for one. Settings →
+ Appearance now offers the choice, defaulting to System, and System now follows your phone's own switch.
+ It applies to the Asleep/Woke row, workout and nap times, caffeine and hydration entries, backup
+ timestamps, sleep-stage timelines and the home-screen widgets. Chart axis ticks stay 24-hour for now:
+ 12-hour labels are wider and the axis spacing needs checking on a device first.
+
+**Sleep**
+
+- **A night can be found from heart rate alone (#1801).** Sleep detection is built on stillness, so a
+ strap that banks no movement scored no nights at all — the everyday reality of a WHOOP 5/MG that will
+ not complete pairing. NOOP can now detect and stage such a night from heart rate. It is display-only by
+ design: an HR-only night never feeds resting heart rate, HRV or SDNN baselines, because it was not
+ established with the evidence those depend on.
+- **The AI coach receives your sleep stages (#1816).** It had been answering that it could not see them,
+ because deep, REM and light minutes were never included in its context. They are now, with efficiency
+ normalised so it cannot arrive as a nonsensical percentage.
+
+**Straps, logs and honesty**
+
+- **The 1970/71 clock warning takes battery into account (#1818).** It previously offered one remedy at
+ any charge level, so people already at 100% were told to charge to 100% and reconnect — advice that was
+ not backed by any precondition in the code.
+- **Every disconnect records what actually happened (#1809, thanks @supremesynergy).** How long the link
+ held, how many frames and bytes arrived, whether the realtime stream was armed, and how it ended. A
+ strap log could not previously state whether a strap had transmitted anything; you had to infer it from
+ every logged line happening to be outgoing, which measures NOOP's logging rather than the strap.
+- **The clock exchange quotes the strap's reply (#1823).** NOOP wrote "clock synced" the moment it queued
+ the write, before any answer existed, so a log could assert the clock was set while the connection
+ readout said 1970/71. It now says what it *sent* and records the reply frame. On a WHOOP 5/MG the
+ readout also claimed "RTC reads 1970/71" when no clock had ever been read on that family; it now
+ reports the evidence it has, which is how the strap dated its records.
+
+**Backup, widgets and interface**
+
+- **Oversize backups (#1807).** Export warns when an archive exceeds the 2 GiB restore ceiling instead of
+ writing one that cannot be restored, and an oversize restore can be allowed through rather than refused.
+- **Home-screen widgets (#1795, thanks @Sneheth; #1799).** Heart-rate and HRV values had swapped icons on
+ Apple. Both platforms' widgets now also read aloud properly, speaking the value rather than a bare
+ number.
+- **Live workout no longer shows two overlapping timers (#1814),** and the steps calibration card stops
+ asking for data already provided (#1815).
+- **Oura rings that advertise no name (#1797, thanks @pipiche38)** label consistently across platforms,
+ and Android's standard heart-rate logging is honest about how much it captured (#1796, thanks @kvnloo).
+
+**Still open**
+
+WHOOP 5/MG history remains unsolved. A strap that will not complete the pairing handshake is never given
+a clock, and an un-clocked 5/MG does not persist sensor data to flash, so its offloads complete empty.
+The diagnostics above exist to separate that from a strap that *was* clocked and still refuses — a
+different fault seen on the same firmware.
+
+---
+
## 9.3.0: Water and caffeine import, honest Effort, and an Oura resting-heart-rate fix (all platforms)
A release about the numbers being right, on top of 9.2.1.
diff --git a/CLAUDE.md b/CLAUDE.md
index 6028f53dfc..532c7d577b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,14 +7,18 @@ and [`docs/IOS.md`](docs/IOS.md) covers the iOS target. Read this first; follow
## What NOOP is (and the hard scope limits)
-NOOP is a **fully offline, on-device** companion app for WHOOP 4.0 and 5.0/MG straps (with
+NOOP is an **offline-by-default, on-device** companion app for WHOOP 4.0 and 5.0/MG straps (with
**experimental** Oura support in the tree — gated behind `ExperimentalBrand`, not a shipped supported
strap). It pairs over Bluetooth, stores everything in on-device SQLite, and computes recovery / strain
-/ HRV / sleep locally. There is **no server, no account, no cloud sync, no telemetry**, and the project stays
-**anonymous** (iOS/Android ship build-from-source / sideload, not via the App Store).
+/ HRV / sleep locally. There is **no NOOP-operated server, no account, no cloud dependency, no
+telemetry**, and the project stays **anonymous** (iOS/Android ship build-from-source / sideload, not
+via the App Store). Issue #1314 permits one narrow exception: a default-off Experimental client may
+export data one way to an HTTP(S) endpoint the user owns and configures. It must remain outside strap
+sync, never read data back, and ship no receiver or hosted service in this repository.
These are hard constraints, not preferences. A PR is out of scope if it:
-- adds a server, account, cloud sync, or sends any data off-device;
+- adds a server, account, cloud dependency, or sends data off-device without the explicit user export
+ boundary in [`docs/SCOPE.md`](docs/SCOPE.md) (including #1314's one-way self-hosted push);
- adds analytics/telemetry/crash-reporting that phones home;
- adds WHOOP firmware, decompiled app code, logos/assets, or any DRM circumvention. NOOP is
**clean-room interoperability** with hardware the user owns — keep it that way. (That bars
@@ -115,7 +119,7 @@ xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand \
### What each CI job covers — and the gaps
| Workflow | Covers | Runner | Default state |
|---|---|---|---|
-| `swift-packages.yml` | `swift test` for **`Packages/**` only** (WhoopProtocol, WhoopStore, StrandAnalytics, StrandImport, StrandDesign, NoopLocalAccess) | macos-15 | **active** |
+| `swift-packages.yml` | TWO jobs. `test`: `swift test` over **`Packages/**`** (WhoopProtocol, WhoopStore, StrandAnalytics, StrandImport, StrandDesign, NoopLocalAccess). `tools`: `swift build` + `swift test` over **`Tools/SleepBench`, `Tools/SleepPSG`, `Tools/Backfill`** — Backfill has no test target, so it is build-only. Path-filtered to those directories. | macos-15 | **active** |
| `app-build.yml` | Builds the **app targets** (`Strand` macOS + `NOOPiOS` iOS) **and runs `StrandTests`** on the macOS/`Strand` leg only — the iOS leg is compile-only. iOS leg needs **macos-26** (iOS 26 SDK / `glassEffect`). | macos-15 / macos-26 | **disabled** (on-demand) |
| `android.yml` | `assembleFullDebug` + `testFullDebugUnitTest` | ubuntu | **active**, path-filtered to `android/**` |
| `source-hygiene.yml` | Doc comments that bind to nothing (`Tools/doc_comment_lint.py`) | ubuntu | **active** |
@@ -132,10 +136,13 @@ properties are initialized`) will pass every green check and still be broken. If
Swift, you MUST build the app yourself: `xcodebuild … build` locally, or run `app-build.yml` on demand.
### Local walls (things that will *not* build where you expect)
-- **On Linux:** only `WhoopProtocol` / `OuraProtocol` (pure) build & test. Every GRDB-linked package —
- `WhoopStore`, `StrandImport`, `StrandAnalytics` (via `WhoopStore`), and `NoopLocalAccess` — fails with
- `sqlite3.h not found` (GRDB's CSQLite), and `StrandDesign` needs SwiftUI — all need **macOS**. Android
- JVM unit tests **do** run on Linux.
+- **On Linux:** `WhoopProtocol` / `OuraProtocol` (pure) build & test with a bare toolchain. The
+ GRDB-linked packages need the snapshot-enabled SQLite build in [`docs/BUILD.md`](docs/BUILD.md) — with
+ it, all four build AND test: `StrandAnalytics` (1523), `WhoopStore` (439), `StrandImport` (249) and
+ `NoopLocalAccess` (9). Without those flags they fail with `sqlite3.h not found` (GRDB's CSQLite). `StrandDesign`
+ needs SwiftUI and is macOS-only. Android JVM unit tests **do** run on Linux.
+ **None of this is CI-enforced** — `swift-packages.yml` is macOS-only, so Linux support is honour-system
+ and a change can break it silently.
- **App targets** (`Strand`, `NOOPiOS`) need **Xcode on macOS**; `StrandTests` runs only under
`xcodebuild … test` on macOS — locally, or via `app-build.yml`, which does run it on the `Strand` leg.
Since that workflow is **disabled by default**, app-target tests are only as validated as your last
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5c4355bd42..863f24dbca 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -60,13 +60,32 @@ cd android
## What CI checks
-Two GitHub Actions workflows run on every PR and push to `main`. They compile and
-run unit tests only — no code signing, no secrets, no release.
+Some GitHub Actions workflows run on *every* PR; others are path-filtered and only run when
+you touch what they cover. All of them compile and run unit tests only — no code signing, no
+secrets, no release. The table is the source of truth, deliberately — the section this
+replaced led with a count, and a count in prose is what goes stale while the list below it
+looks fine.
-| Workflow | Trigger | What it does |
+The check names GitHub shows you are **job** names, which do not resemble the workflow
+names. That column is why this table exists:
+
+| Check you see | Workflow | Runs when |
|---|---|---|
-| **Swift Packages CI** (`.github/workflows/swift-packages.yml`) | changes under `Packages/**` | `swift build` + `swift test` for each package |
-| **Android CI** (`.github/workflows/android.yml`) | changes under `android/**` | `assembleFullDebug` + `testFullDebugUnitTest` (JDK 17) |
+| `check` | **i18n Coverage** (`i18n-coverage.yml`) | every PR |
+| `doc-comments` | **Source Hygiene** (`source-hygiene.yml`) | every PR |
+| `linux-capture` | **Tools Python CI** (`tools-python.yml`) | every PR |
+| `build-and-test` | **Android CI** (`android.yml`) | `android/**`, the protocol/store test resources, `Strand/Resources/Localizable.xcstrings` |
+| `test (…)`, `tools (…)` | **Swift Packages CI** (`swift-packages.yml`) | `Packages/**`, the `Tools/SleepBench`, `Tools/SleepPSG` and `Tools/Backfill` packages, `android/app/src/test/resources/**`, `Strand/Liquid/LiquidCore.swift` |
+
+Read that as a worked example: an Android-only PR runs Android CI plus the three that always
+run, so a short list of checks does not mean little was checked.
+
+**App build** (`app-build.yml`, app-target compile + the `StrandTests` macOS suite) is
+`disabled_manually` and is **not** in that list. App-target code — SwiftUI views,
+`BLEManager`, `Repository`, Compose screens — is compiled by **nothing** on a normal PR, so
+build it locally before you push, or ask a maintainer to dispatch `app-build.yml`. See
+[docs/CONTRIBUTING.md](docs/CONTRIBUTING.md#what-ci-gates--and-what-it-deliberately-doesnt)
+for why the lean setup is deliberate and what else is gated at release time instead.
If CI fails on your PR, fix the cause rather than working around it. Never commit
generated output (`Strand.xcodeproj/`) or any secrets, keystores, or `local.properties`.
diff --git a/Packages/NoopLocalAccess/README.md b/Packages/NoopLocalAccess/README.md
new file mode 100644
index 0000000000..82fef2efc7
--- /dev/null
+++ b/Packages/NoopLocalAccess/README.md
@@ -0,0 +1,30 @@
+# NoopLocalAccess
+
+`noop-local-access` exposes bounded, read-only NOOP health data locally. It has no network or
+write/control path.
+
+Use MCP over stdio with `noop-local-access mcp`, or query one tool directly as JSON:
+
+```sh
+noop-local-access query health_snapshot --days 14
+noop-local-access query metric_series --key hrv --days 90
+noop-local-access query data_freshness
+noop-local-access query sleep_summary --days 30
+noop-local-access query workout_summary --days 90
+```
+
+Set `NOOP_DB_PATH` to select a database, or pass `--db-path PATH`. Query results are written to
+stdout; diagnostics are written to stderr. Query usage errors exit 64 and runtime/database errors
+exit 1.
+
+Arguments reuse the MCP defaults and bounds:
+
+- `health_snapshot`: optional `--days` (default 14, clamped to 1...120).
+- `metric_series`: required `--key`; optional `--source` (default `my-whoop`), `--days` (default 90,
+ clamped to 1...4000), `--from-day`, `--to-day`, and `--limit` (default 500, clamped to 1...2000).
+- `data_freshness`: no tool arguments.
+- `sleep_summary`: optional `--days` (default 30, clamped to 1...4000).
+- `workout_summary`: optional `--days` (default 90, clamped to 1...4000).
+
+Dates use the existing `YYYY-MM-DD` tool contract. The CLI does not add a separate validation or
+interpretation layer; it passes accepted arguments to the same bounded read-only dispatcher as MCP.
diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/CLIQuery.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/CLIQuery.swift
new file mode 100644
index 0000000000..36ac3d69a5
--- /dev/null
+++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/CLIQuery.swift
@@ -0,0 +1,118 @@
+import Foundation
+
+public struct NoopCLIQueryRequest: Equatable, Sendable {
+ public let toolName: String
+ public let arguments: [String: JSONValue]
+ public let configuration: LocalAccessConfiguration
+
+ public init(
+ toolName: String,
+ arguments: [String: JSONValue],
+ configuration: LocalAccessConfiguration = .environment()
+ ) {
+ self.toolName = toolName
+ self.arguments = arguments
+ self.configuration = configuration
+ }
+}
+
+public enum NoopCLIQueryError: Error, CustomStringConvertible, Equatable {
+ case usage(String)
+
+ public var description: String {
+ switch self {
+ case .usage(let message): return message
+ }
+ }
+
+ public var exitCode: Int32 { 64 }
+}
+
+public enum NoopCLIQuery {
+ public static func parse(arguments: [String]) throws -> NoopCLIQueryRequest {
+ guard let toolName = arguments.first, !toolName.hasPrefix("-") else {
+ throw NoopCLIQueryError.usage("query requires one tool name")
+ }
+ guard NoopToolDispatcher.toolNames.contains(toolName) else {
+ throw NoopCLIQueryError.usage("unknown query tool")
+ }
+
+ var toolArguments: [String: JSONValue] = [:]
+ var configuration = LocalAccessConfiguration.environment()
+ var seenFlags = Set()
+ var index = 1
+
+ while index < arguments.count {
+ let flag = arguments[index]
+ guard flag.hasPrefix("--") else {
+ throw NoopCLIQueryError.usage("query does not accept additional positional arguments")
+ }
+ guard seenFlags.insert(flag).inserted else {
+ throw NoopCLIQueryError.usage("duplicate query flag: \(flag)")
+ }
+ index += 1
+
+ switch flag {
+ case "--db-path":
+ configuration.databasePath = try requiredValue(flag, arguments: arguments, index: &index)
+ case "--days":
+ guard toolName != "data_freshness" else { throw unsupported(flag, toolName: toolName) }
+ toolArguments["days"] = try integerValue(flag, arguments: arguments, index: &index)
+ case "--key":
+ guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) }
+ toolArguments["key"] = .string(try requiredValue(flag, arguments: arguments, index: &index))
+ case "--source":
+ guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) }
+ toolArguments["source"] = .string(try requiredValue(flag, arguments: arguments, index: &index))
+ case "--from-day":
+ guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) }
+ toolArguments["from_day"] = .string(try requiredValue(flag, arguments: arguments, index: &index))
+ case "--to-day":
+ guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) }
+ toolArguments["to_day"] = .string(try requiredValue(flag, arguments: arguments, index: &index))
+ case "--limit":
+ guard toolName == "metric_series" else { throw unsupported(flag, toolName: toolName) }
+ toolArguments["limit"] = try integerValue(flag, arguments: arguments, index: &index)
+ default:
+ throw NoopCLIQueryError.usage("unknown query flag")
+ }
+ }
+
+ if toolName == "metric_series", toolArguments["key"] == nil {
+ throw NoopCLIQueryError.usage("metric_series requires --key")
+ }
+
+ return NoopCLIQueryRequest(toolName: toolName, arguments: toolArguments, configuration: configuration)
+ }
+
+ public static func dispatch(_ request: NoopCLIQueryRequest) throws -> JSONValue {
+ try NoopToolDispatcher(configuration: request.configuration)
+ .dispatch(name: request.toolName, arguments: request.arguments)
+ }
+
+ public static func encodeLine(_ value: JSONValue) throws -> Data {
+ var data = try JSONEncoder().encode(value)
+ data.append(0x0A)
+ return data
+ }
+
+ private static func requiredValue(_ flag: String, arguments: [String], index: inout Int) throws -> String {
+ guard index < arguments.count, !arguments[index].hasPrefix("--") else {
+ throw NoopCLIQueryError.usage("missing value for \(flag)")
+ }
+ defer { index += 1 }
+ return arguments[index]
+ }
+
+ private static func integerValue(_ flag: String, arguments: [String], index: inout Int) throws -> JSONValue {
+ let raw = try requiredValue(flag, arguments: arguments, index: &index)
+ guard let value = Int(raw) else {
+ throw NoopCLIQueryError.usage("value for \(flag) must be an integer")
+ }
+ return .int(value)
+ }
+
+ private static func unsupported(_ flag: String, toolName: String) -> NoopCLIQueryError {
+ .usage("\(flag) is not supported for \(toolName)")
+ }
+}
diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift
index 632e181a28..db34c46f3d 100644
--- a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift
+++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift
@@ -323,16 +323,29 @@ public final class ReadonlyNoopStore {
try dbQueue.read { db in
// Every durable decoded per-second table. The four below the first line were landed as
// instrumentation and then left OUT of this count, which is how an unbounded, unpruned table
- // became invisible: `ppgWaveformSample` banks a ~48-byte BLOB per v26 strap-second and
- // `v18AuxSample` ~30 bytes per v18 strap-second, neither is pruned (deliberately — this is
- // decoded biometric history, not the transient raw outbox), and until now neither showed up in
- // the only readout a user has for "what is the store spending space on". Growth that nothing
- // reads still has to be growth somebody can SEE. Guarded by `tableNames.contains` so a store
- // predating any of these migrations still reports.
+ // became invisible: `ppgWaveformSample` banks a ~48-byte BLOB per v26 strap-second, and until
+ // it was counted it did not show up in the only readout a user has for "what is the store
+ // spending space on". Growth that nothing reads still has to be growth somebody can SEE.
+ //
+ // BOTH blob tables are now bounded, and neither claim below is safe to let go stale, because
+ // "which tables are already bounded" is the first thing a retention design reads off — getting
+ // it wrong has such a design solving a problem twice, or trusting a bound that was never
+ // there. `v18AuxSample`: rolling retention since `WhoopStore.v18AuxRetentionRows` /
+ // `v18AuxPruneEveryRows` (Room migration v31, covered by `DeepCaptureChannelsTests`).
+ // `ppgWaveformSample`: rolling retention since #1911 (`WhoopStore.ppgWaveformRetentionRows` /
+ // `ppgWaveformPruneEveryRows`, covered by `PpgWaveformSampleTests`). Both keep ~604,800 NEWEST
+ // rows per device — a row cap, NOT an age cutoff, so a sporadic wearer keeps a full working
+ // set. Every other decoded per-second table listed here is still genuinely unpruned.
+ //
+ // This list is a DELIBERATE fourth copy, not drift. The other three (`WhoopStore.rawTableKeys`,
+ // the footprint total and the timestamp heal) now share one list; this module cannot, because
+ // NoopLocalAccess depends on GRDB alone and stays independent of WhoopStore on purpose — it is
+ // the read-only access tool, and a dependency on the app's store package would defeat that.
+ // Guarded by `tableNames.contains` so a store predating any of these migrations still reports.
let decodedTables = [
"hrSample", "rrInterval", "event", "battery", "spo2Sample",
"skinTempSample", "respSample", "gravitySample", "ppgHrSample", "stepSample",
- "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample",
+ "sleepStateSample", "ppgWaveformSample", "v18AuxSample",
]
var decodedRows = 0
for table in decodedTables where tableNames.contains(table) {
diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift
index a5d34a5557..8509f0cd21 100644
--- a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift
+++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/MCPServer.swift
@@ -17,11 +17,10 @@ public struct RPCRequest: Decodable, Equatable {
}
public final class NoopMCPServer {
- private let configuration: LocalAccessConfiguration
- private var dataAccess: NoopDataAccess?
+ private let dispatcher: NoopToolDispatcher
public init(configuration: LocalAccessConfiguration = .environment()) {
- self.configuration = configuration
+ dispatcher = NoopToolDispatcher(configuration: configuration)
}
public func handleLine(_ line: String) -> JSONValue {
@@ -113,19 +112,6 @@ public final class NoopMCPServer {
NOOP local access is read-only and returns personal health context from the user's on-device SQLite store. Use bounded tools, check data_freshness before stale-data claims, separate facts from inference, and do not diagnose medical conditions. No tool writes data or calls a network service.
"""
- private func data() throws -> NoopDataAccess {
- if let dataAccess { return dataAccess }
- do {
- let access = try NoopDataAccess.open(configuration: configuration)
- dataAccess = access
- return access
- } catch let error as LocalAccessError {
- throw error
- } catch {
- throw LocalAccessError.databaseUnavailable("NOOP database is not available: \(error)")
- }
- }
-
private func callTool(params: JSONValue?) throws -> JSONValue {
guard let object = params?.objectValue,
let name = object["name"]?.stringValue
@@ -133,51 +119,14 @@ public final class NoopMCPServer {
throw LocalAccessError.invalidParams("tools/call requires a tool name")
}
let arguments = object["arguments"]?.objectValue ?? [:]
- let payload: JSONValue
- switch name {
- case "health_snapshot":
- payload = try data().healthSnapshot(days: boundedDays(arguments["days"], default: 14, max: 120))
- case "metric_series":
- guard let key = arguments["key"]?.stringValue else {
- throw LocalAccessError.invalidParams("metric_series requires key")
- }
- payload = try data().metricSeries(
- key: key,
- source: arguments["source"]?.stringValue ?? "my-whoop",
- days: boundedDays(arguments["days"], default: 90, max: 4000),
- fromDay: arguments["from_day"]?.stringValue,
- toDay: arguments["to_day"]?.stringValue,
- limit: boundedLimit(arguments["limit"], default: 500, max: 2000)
- )
- case "data_freshness":
- payload = try data().freshness()
- case "sleep_summary":
- payload = try data().sleepSummary(days: boundedDays(arguments["days"], default: 30, max: 4000))
- case "workout_summary":
- payload = try data().workoutSummary(days: boundedDays(arguments["days"], default: 90, max: 4000))
- default:
- throw LocalAccessError.toolNotFound(name)
- }
- return toolResult(payload)
+ return toolResult(try dispatcher.dispatch(name: name, arguments: arguments))
}
private func readResource(params: JSONValue?) throws -> JSONValue {
guard let uri = params?.objectValue?["uri"]?.stringValue else {
throw LocalAccessError.invalidParams("resources/read requires uri")
}
- let payload: JSONValue
- switch uri {
- case "noop://health/snapshot":
- payload = try data().healthSnapshot(days: 14)
- case "noop://data/freshness":
- payload = try data().freshness()
- case "noop://metrics/catalog":
- payload = NoopDataAccess.metricCatalog()
- case "noop://sources":
- payload = NoopDataAccess.sources()
- default:
- throw LocalAccessError.resourceNotFound(uri)
- }
+ let payload = try dispatcher.resourcePayload(uri: uri)
return .object([
"contents": .array([
.object([
diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/ToolDispatcher.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/ToolDispatcher.swift
new file mode 100644
index 0000000000..634ebaeaad
--- /dev/null
+++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/ToolDispatcher.swift
@@ -0,0 +1,74 @@
+import Foundation
+
+/// The single read-only dispatch surface shared by MCP and the direct CLI transport.
+public final class NoopToolDispatcher {
+ private let configuration: LocalAccessConfiguration
+ private var dataAccess: NoopDataAccess?
+
+ public init(configuration: LocalAccessConfiguration = .environment()) {
+ self.configuration = configuration
+ }
+
+ public static let toolNames = [
+ "health_snapshot",
+ "metric_series",
+ "data_freshness",
+ "sleep_summary",
+ "workout_summary",
+ ]
+
+ public func dispatch(name: String, arguments: [String: JSONValue] = [:]) throws -> JSONValue {
+ switch name {
+ case "health_snapshot":
+ return try data().healthSnapshot(days: boundedDays(arguments["days"], default: 14, max: 120))
+ case "metric_series":
+ guard let key = arguments["key"]?.stringValue else {
+ throw LocalAccessError.invalidParams("metric_series requires key")
+ }
+ return try data().metricSeries(
+ key: key,
+ source: arguments["source"]?.stringValue ?? "my-whoop",
+ days: boundedDays(arguments["days"], default: 90, max: 4000),
+ fromDay: arguments["from_day"]?.stringValue,
+ toDay: arguments["to_day"]?.stringValue,
+ limit: boundedLimit(arguments["limit"], default: 500, max: 2000)
+ )
+ case "data_freshness":
+ return try data().freshness()
+ case "sleep_summary":
+ return try data().sleepSummary(days: boundedDays(arguments["days"], default: 30, max: 4000))
+ case "workout_summary":
+ return try data().workoutSummary(days: boundedDays(arguments["days"], default: 90, max: 4000))
+ default:
+ throw LocalAccessError.toolNotFound(name)
+ }
+ }
+
+ public func resourcePayload(uri: String) throws -> JSONValue {
+ switch uri {
+ case "noop://health/snapshot":
+ return try dispatch(name: "health_snapshot", arguments: ["days": .int(14)])
+ case "noop://data/freshness":
+ return try dispatch(name: "data_freshness")
+ case "noop://metrics/catalog":
+ return NoopDataAccess.metricCatalog()
+ case "noop://sources":
+ return NoopDataAccess.sources()
+ default:
+ throw LocalAccessError.resourceNotFound(uri)
+ }
+ }
+
+ private func data() throws -> NoopDataAccess {
+ if let dataAccess { return dataAccess }
+ do {
+ let access = try NoopDataAccess.open(configuration: configuration)
+ dataAccess = access
+ return access
+ } catch let error as LocalAccessError {
+ throw error
+ } catch {
+ throw LocalAccessError.databaseUnavailable("NOOP database is not available: \(error)")
+ }
+ }
+}
diff --git a/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift b/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift
index 5f95ed2f44..94730f6423 100644
--- a/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift
+++ b/Packages/NoopLocalAccess/Sources/noop-local-access/main.swift
@@ -11,6 +11,8 @@ enum NoopLocalAccessMain {
switch command {
case "mcp":
runMCP(configuration: .environment())
+ case "query":
+ runQuery(arguments: args)
case "codex-config":
print(codexConfig(arguments: args))
case "--help", "-h", "help":
@@ -21,6 +23,33 @@ enum NoopLocalAccessMain {
}
}
+ private static func runQuery(arguments: [String]) {
+ if arguments == ["--help"] || arguments == ["-h"] {
+ print(helpText)
+ return
+ }
+ do {
+ let request = try NoopCLIQuery.parse(arguments: arguments)
+ let payload = try NoopCLIQuery.dispatch(request)
+ FileHandle.standardOutput.write(try NoopCLIQuery.encodeLine(payload))
+ } catch let error as NoopCLIQueryError {
+ fputs("[noop-local-access] \(error)\n", stderr)
+ Foundation.exit(error.exitCode)
+ } catch let error as LocalAccessError {
+ let message: String
+ if case .databaseUnavailable = error {
+ message = "NOOP database is unavailable"
+ } else {
+ message = error.description
+ }
+ fputs("[noop-local-access] \(message)\n", stderr)
+ Foundation.exit(1)
+ } catch {
+ fputs("[noop-local-access] runtime error\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
private static func runMCP(configuration: LocalAccessConfiguration) {
let server = NoopMCPServer(configuration: configuration)
while let line = readLine(strippingNewline: true) {
@@ -77,8 +106,19 @@ enum NoopLocalAccessMain {
private static let helpText = """
Usage:
noop-local-access mcp
+ noop-local-access query [flags]
noop-local-access codex-config [--db-path /absolute/path/to/whoop.sqlite]
+ Query tools:
+ health_snapshot [--days N]
+ metric_series --key KEY [--source SOURCE] [--days N] [--from-day YYYY-MM-DD] [--to-day YYYY-MM-DD] [--limit N]
+ data_freshness
+ sleep_summary [--days N]
+ workout_summary [--days N]
+
+ Query options:
+ --db-path PATH Explicit NOOP SQLite path. Otherwise NOOP_DB_PATH or the official app container is used.
+
Environment:
NOOP_DB_PATH Explicit NOOP SQLite path. Optional; otherwise the official macOS app container is used.
NOOP_BUNDLE_ID Optional non-default bundle id. Not needed for the official app.
diff --git a/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/CLIQueryTests.swift b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/CLIQueryTests.swift
new file mode 100644
index 0000000000..0e2ea7d628
--- /dev/null
+++ b/Packages/NoopLocalAccess/Tests/NoopLocalAccessCoreTests/CLIQueryTests.swift
@@ -0,0 +1,126 @@
+import XCTest
+@testable import NoopLocalAccessCore
+
+final class CLIQueryTests: XCTestCase {
+ func testAllFiveQueriesDispatchThroughTheSamePayloadAsMCP() throws {
+ let url = try TemporaryDatabase.seeded()
+ let configuration = LocalAccessConfiguration(databasePath: url.path)
+ let queries: [(String, [String])] = [
+ ("health_snapshot", ["--days", "14"]),
+ ("metric_series", ["--key", "hrv", "--from-day", "2026-06-10", "--to-day", "2026-06-11"]),
+ ("data_freshness", []),
+ ("sleep_summary", ["--days", "30"]),
+ ("workout_summary", ["--days", "90"]),
+ ]
+
+ for (tool, flags) in queries {
+ let parsed = try NoopCLIQuery.parse(arguments: [tool] + flags)
+ let request = NoopCLIQueryRequest(
+ toolName: parsed.toolName,
+ arguments: parsed.arguments,
+ configuration: configuration
+ )
+ let cliPayload = try NoopCLIQuery.dispatch(request)
+ let response = try XCTUnwrap(try NoopMCPServer(configuration: configuration).handle(RPCRequest(
+ id: .int(1),
+ method: "tools/call",
+ params: .object([
+ "name": .string(tool),
+ "arguments": .object(parsed.arguments),
+ ])
+ )))
+ let mcpPayload = try XCTUnwrap(response.objectValue?["result"]?.objectValue?["structuredContent"])
+
+ XCTAssertEqual(
+ withoutVolatileFields(mcpPayload),
+ withoutVolatileFields(cliPayload),
+ "CLI/MCP payload mismatch for \(tool)"
+ )
+ }
+ }
+
+ func testMetricSeriesRequiresKeyAndRejectsUnknownOrMissingFlags() {
+ assertUsageError([])
+ assertUsageError(["metric_series"])
+ assertUsageError(["metric_series", "--unknown", "x"])
+ assertUsageError(["metric_series", "--key=hrv"])
+ assertUsageError(["metric_series", "--key"])
+ assertUsageError(["metric_series", "--key", "hrv", "--limit"])
+ assertUsageError(["metric_series", "--key", "hrv", "--key", "rhr"])
+ assertUsageError(["metric_series", "--key", "hrv", "extra"])
+ assertUsageError(["health_snapshot", "--db-path"])
+ assertUsageError(["data_freshness", "--days", "7"])
+ assertUsageError(["health_snapshot", "--days", "not-an-integer"])
+ }
+
+ func testMetricSeriesParsesTheCompleteFlagContract() throws {
+ let parsed = try NoopCLIQuery.parse(arguments: [
+ "metric_series",
+ "--key", "hrv",
+ "--source", "apple-health",
+ "--days", "30",
+ "--from-day", "2026-06-01",
+ "--to-day", "2026-06-30",
+ "--limit", "42",
+ "--db-path", "/tmp/noop.sqlite",
+ ])
+
+ XCTAssertEqual(parsed.toolName, "metric_series")
+ XCTAssertEqual(parsed.arguments, [
+ "key": .string("hrv"),
+ "source": .string("apple-health"),
+ "days": .int(30),
+ "from_day": .string("2026-06-01"),
+ "to_day": .string("2026-06-30"),
+ "limit": .int(42),
+ ])
+ XCTAssertEqual(parsed.configuration.databasePath, "/tmp/noop.sqlite")
+ }
+
+ func testCLIValuesArePassedToTheExistingBounds() throws {
+ let parsed = try NoopCLIQuery.parse(arguments: ["sleep_summary", "--days", "0"])
+ XCTAssertEqual(parsed.arguments["days"], .int(0))
+
+ let request = NoopCLIQueryRequest(
+ toolName: parsed.toolName,
+ arguments: parsed.arguments,
+ configuration: LocalAccessConfiguration(databasePath: try TemporaryDatabase.seeded().path)
+ )
+ let payload = try NoopCLIQuery.dispatch(request)
+ XCTAssertEqual(payload.objectValue?["range"]?.objectValue?["days"], .int(1))
+ }
+
+ func testSuccessfulOutputIsOneJSONValueAndEncodingFailuresPropagate() throws {
+ let value: JSONValue = .object(["ok": .bool(true)])
+ let line = try NoopCLIQuery.encodeLine(value)
+
+ XCTAssertEqual(line.last, 0x0A)
+ XCTAssertEqual(try JSONDecoder().decode(JSONValue.self, from: Data(line.dropLast())), value)
+ XCTAssertThrowsError(try NoopCLIQuery.encodeLine(.double(.infinity)))
+ }
+
+ private func assertUsageError(_ arguments: [String], file: StaticString = #filePath, line: UInt = #line) {
+ XCTAssertThrowsError(try NoopCLIQuery.parse(arguments: arguments), file: file, line: line) { error in
+ guard let error = error as? NoopCLIQueryError else {
+ return XCTFail("Expected usage error, got \(error)", file: file, line: line)
+ }
+ XCTAssertEqual(error.exitCode, 64, file: file, line: line)
+ }
+ }
+
+ private func withoutVolatileFields(_ value: JSONValue) -> JSONValue {
+ switch value {
+ case .array(let values):
+ return .array(values.map(withoutVolatileFields))
+ case .object(let object):
+ let volatile = Set(["generatedAt", "ageSeconds", "fromTs", "toTs"])
+ var normalized: [String: JSONValue] = [:]
+ for (key, value) in object where !volatile.contains(key) {
+ normalized[key] = withoutVolatileFields(value)
+ }
+ return .object(normalized)
+ default:
+ return value
+ }
+ }
+}
diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift b/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift
index a63f55a0f1..0c75875db3 100644
--- a/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift
+++ b/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift
@@ -20,8 +20,12 @@ public enum OuraCommands {
public static let featureDaytimeHR: UInt8 = 0x02
// The SpO2 feature id. Per OURA_PROTOCOL.md s7.1.
public static let featureSpO2: UInt8 = 0x04
- // The real-steps feature id. Server-flag-gated (activity/real_steps, default off), so it is never
- // emitted for an offline NOOP-only ring. Per OURA_PROTOCOL.md s7.1 / s7.3 [open_oura-feat].
+ // The real-steps feature id (`activity/real_steps`). Nominally server-flag-gated per
+ // OURA_PROTOCOL.md s7.1/s7.3 [open_oura-feat], but on-device 2026-08-25 real-steps status reads
+ // came back enabled (status=1) from BOTH an authenticated Oura-app session and NOOP's own fully
+ // offline, unauthenticated read of the same ring — the gate is ring-side state, not tied to which
+ // client asks or whether that client is cloud-authenticated. Do not assume "off for NOOP" without
+ // checking a live read.
public static let featureRealSteps: UInt8 = 0x0B
// MARK: - Pre-auth / identity (unauthenticated OK)
@@ -148,9 +152,11 @@ public enum OuraCommands {
OuraCommand(label: "spo2_status", bytes: [0x2F, 0x02, 0x20, featureSpO2])
}
- /// Read the real-steps feature status, `2f 02 20 0b` (READ verb, not enable). The `0x21` reply confirms
- /// the server-flag gate (`activity/real_steps`, default off) from the ring itself. Read-only diagnostic.
- /// [open_oura-feat]
+ /// Read the real-steps feature status, `2f 02 20 0b` (READ verb, not enable). The `0x21` reply reports
+ /// the ring's own real_steps gate state — NOT reliably "off" for an offline ring: on-device
+ /// 2026-08-25 this read back status=1 (enabled) from NOOP's own unauthenticated connection, matching
+ /// the real Oura app's read of the same ring byte-for-byte. Read-only diagnostic either way — never
+ /// enables anything, never writes a mode. [open_oura-feat]
public static func realStepsReadStatus() -> OuraCommand {
OuraCommand(label: "realsteps_status", bytes: [0x2F, 0x02, 0x20, featureRealSteps])
}
diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift
index dd1b13a018..10e6258e7d 100644
--- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift
+++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift
@@ -246,17 +246,34 @@ public final class OuraDriver {
return Int(seconds)
}
+ /// How far the ring's clock may run AHEAD of `lowerBoundTicks` and still be recognised. The bound is a
+ /// stale resume cursor or the oldest ring-time a drain has seen, either of which can trail the ring's
+ /// clock by the ring's whole banked depth (~14 days) plus however long the cursor has been stuck — the
+ /// original 7-day window silently excluded exactly that case: in the 2026-09-02/03 iOS captures an
+ /// 8.2-day-stale cursor could never be re-anchored, so it could never advance, so the staleness only
+ /// grew — one full re-serve of the same window per launch, forever. Widening costs no
+ /// honesty: both readings fit the window only when `window >= 9 × lowerBound`, i.e. a ring under ~5
+ /// days of clock — and that case still resolves to nil below, exactly as before.
+ public static let syncTimeAnchorWindowTicks: Int64 = 38_880_000 // 45 days of 100 ms ticks
+
/// Resolve the 0x13 SyncTime-response device timestamp into ring TICKS, or nil when no unambiguous
/// reading exists. ringverse BLE.md labels the field "seconds" but the ring's record clock runs in
/// 100 ms ticks, so both readings are tried: the raw value (already ticks) and value×10 (seconds→
- /// ticks). The ring's clock at connect must sit shortly AFTER where the last drain ended, so a
- /// candidate is plausible iff it falls in `[historyCursor, historyCursor + 7 days]`; exactly one
- /// must fit (ambiguity or a fresh/reset cursor → nil → the caller logs raw instead of guessing).
+ /// ticks). The ring's clock at connect must sit AFTER any ring-time we already know about, so a
+ /// candidate is plausible iff it falls in `[lowerBoundTicks, lowerBoundTicks +
+ /// syncTimeAnchorWindowTicks]`; exactly one must fit (ambiguity or no reference at all → nil → the
+ /// caller logs raw instead of guessing).
+ ///
+ /// `lowerBoundTicks` is any ring-time known to precede the ring's clock NOW: the persisted resume
+ /// cursor at connect, or — when that is 0 (fresh pair / post-reboot reset) or too stale — the largest
+ /// envelope ring-time the drain has actually seen (`OuraHistoryDrain.maxSeenRingTime`), which needs no
+ /// anchor to read and so breaks the cursor↔anchor deadlock (2026-09-02/03 captures).
+ ///
/// Pure and testable; the honest-data invariant is "no anchor beats a wrong anchor".
- public static func syncTimeAnchorCandidate(responseValue: UInt32, historyCursor: UInt32) -> UInt32? {
- guard historyCursor > 0 else { return nil }
- let lower = Int64(historyCursor)
- let upper = lower + 6_048_000 // 7 days of 100 ms ticks
+ public static func syncTimeAnchorCandidate(responseValue: UInt32, lowerBoundTicks: UInt32) -> UInt32? {
+ guard lowerBoundTicks > 0 else { return nil }
+ let lower = Int64(lowerBoundTicks)
+ let upper = lower + syncTimeAnchorWindowTicks
let readings = [Int64(responseValue), Int64(responseValue) * 10]
let fits = readings.filter { $0 >= lower && $0 <= upper && $0 <= Int64(UInt32.max) }
guard fits.count == 1 else { return nil }
diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift
index d6858928e8..7187c5772f 100644
--- a/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift
+++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/FramingTests.swift
@@ -219,22 +219,54 @@ final class FramingTests: XCTestCase {
// MARK: - 0x13 -> anchor tick disambiguation (OuraDriver.syncTimeAnchorCandidate)
func testSyncTimeAnchorCandidateResolvesUnit() {
- // The 2026-07-13 shape: cursor banked at 4_413_933 (last night's log end); ~11 h later the
- // ring's clock is ~4.81M ticks. A raw-ticks response fits the [cursor, cursor+7d] window and
+ // The 2026-07-13 shape: floor banked at 4_413_933 (last night's log end); ~11 h later the
+ // ring's clock is ~4.81M ticks. A raw-ticks response fits the [floor, floor+45d] window and
// the seconds x10 reading does not -> unambiguous ticks.
- XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, historyCursor: 4_413_933),
+ XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, lowerBoundTicks: 4_413_933),
4_810_000)
// A seconds-unit response (481_000 s = 4.81M ticks) only fits when multiplied x10.
- XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 481_000, historyCursor: 4_413_933),
+ XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 481_000, lowerBoundTicks: 4_413_933),
4_810_000)
- // Below the cursor in both readings (ring reboot / stale value) -> nil.
- XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 100_000, historyCursor: 4_413_933))
- // Beyond cursor+7d in both readings -> nil.
- XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 40_000_000, historyCursor: 4_413_933))
- // A fresh/reset cursor gives no reference -> nil (never guess on a full pull).
- XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, historyCursor: 0))
- // Ambiguity guard: BOTH readings inside the window -> nil.
- XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 150_000, historyCursor: 140_000))
+ // Below the floor in both readings (ring reboot / stale value) -> nil.
+ XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 100_000, lowerBoundTicks: 4_413_933))
+ // Beyond floor+45d in both readings -> nil.
+ XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 50_000_000, lowerBoundTicks: 4_413_933))
+ // No reference at all -> nil (never guess).
+ XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_810_000, lowerBoundTicks: 0))
+ // Ambiguity guard: BOTH readings inside the window -> nil. Reachable only while the floor is
+ // under window/9 (~5 days of ring clock), i.e. a barely-run ring.
+ XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 150_000, lowerBoundTicks: 140_000))
+ }
+
+ /// Regression from the 2026-09-02/03 iOS captures. The ring's 0x13 reply reads 0x0218767f =
+ /// 35_157_631 ticks while the persisted resume cursor sits at 28_073_725 — 8.20 days behind. Under the
+ /// old 7-day window neither reading fit, so no anchor was adopted; with no anchor the drain-end commit
+ /// could not advance the cursor (`resumeCursorAtDrainEnd(resolvesUnderAnchor: false)` returns it
+ /// unchanged), so the cursor stayed stale and the gap only grew — a permanent loop, one full re-serve
+ /// of the same window per launch. The widened window resolves it, unambiguously.
+ func testSyncTimeAnchorCandidateAcceptsAStaleCursorFromTheCaptures() {
+ let staleCursor: UInt32 = 28_073_725 // banked 2026-08-26 02:49:56 by the 300 s guard
+ for reply: UInt32 in [0x0218767f, 0x02187ce8, 0x02189fce] { // the three 09-03 launches
+ XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: reply,
+ lowerBoundTicks: staleCursor),
+ reply, "0x\(String(reply, radix: 16)) must resolve as raw ticks")
+ }
+ // The old 7-day window is what excluded it: 28_073_725 + 6_048_000 = 34_121_725 < 35_157_631.
+ XCTAssertGreaterThan(OuraDriver.syncTimeAnchorWindowTicks, 35_157_631 - Int64(staleCursor),
+ "the window must cover the observed 8.2-day staleness")
+ }
+
+ /// The other half of the deadlock: on a fresh pair / post-reboot reset the cursor is 0, so it
+ /// can never be the reference. The drain's `maxSeenRingTime` can — it counts EVERY history record's
+ /// envelope time and needs no anchor to read — so the caller retries the parked reply against it.
+ func testSyncTimeAnchorCandidateResolvesAgainstSeenRingTimeWhenCursorIsZero() {
+ let reply: UInt32 = 0x0211dbd0 // 34_724_816 — the 2026-09-02 capture, cursor 0
+ XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: reply, lowerBoundTicks: 0))
+ // First batch of a full pull lands the ring's OLDEST banked record (~14 days back).
+ let oldestBanked: UInt32 = 34_724_816 - 12_096_000
+ XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: reply,
+ lowerBoundTicks: oldestBanked),
+ reply)
}
func testAdoptSyncTimeAnchorResolvesHistoryTimes() {
diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift
index fbb046104b..4a0e4d9c41 100644
--- a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift
+++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureStatusTests.swift
@@ -42,14 +42,27 @@ final class OuraFeatureStatusTests: XCTestCase {
func testSpO2AndStepsReadsSurfaceFeatureStatus() {
let d = OuraDriver(ringGen: .gen3, authKey: key)
- // SpO2 (0x04): subscription 0 → server-gated off.
+ // An all-zero reply. The comment here used to read the zeros as "server-gated off"; it describes
+ // the FIXTURE, not a ring's real answer, and #1629 showed a real ring answering otherwise.
XCTAssertEqual(d.handleSecureFrame(OuraSecureFrame(subop: 0x21, subBody: [0x04, 0x00, 0x00, 0x00, 0x00])),
.featureStatus(OuraFeatureStatus(feature: 0x04, mode: 0, status: 0, state: 0, subscription: 0)))
- // real_steps (0x0b): subscription 0 → server-gated off.
XCTAssertEqual(d.handleSecureFrame(OuraSecureFrame(subop: 0x21, subBody: [0x0B, 0x00, 0x00, 0x00, 0x00])),
.featureStatus(OuraFeatureStatus(feature: 0x0B, mode: 0, status: 0, state: 0, subscription: 0)))
}
+ /// #1629: the shape a REAL ring actually answers with. On-device captures read real_steps back as
+ /// `status=1` from both an authenticated Oura-app session and NOOP's own offline, unauthenticated
+ /// connection to the same ring. Until now the suite only ever exercised the all-zero reply, so the
+ /// one reading anybody has actually observed on hardware was the one nothing pinned.
+ ///
+ /// This asserts the DECODE only. `status=1` says the ring reports the feature enabled; it is not
+ /// evidence that the ring emits `0x7E`/`0x7F`, and nothing here should be read as claiming that.
+ func testAnEnabledRealStepsStatusDecodesAsObservedOnHardware() {
+ let d = OuraDriver(ringGen: .gen3, authKey: key)
+ XCTAssertEqual(d.handleSecureFrame(OuraSecureFrame(subop: 0x21, subBody: [0x0B, 0x00, 0x01, 0x00, 0x00])),
+ .featureStatus(OuraFeatureStatus(feature: 0x0B, mode: 0, status: 1, state: 0, subscription: 0)))
+ }
+
func testUndecodableDiagnosticReplyFallsBackToEnableAck() {
// A too-short 0x21 (can't decode a feature) must not crash or mis-route; it stays `.enableAck` so
// the triplet never stalls on a malformed reply.
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AdaptiveExpenditureEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AdaptiveExpenditureEngine.swift
new file mode 100644
index 0000000000..0789991c4a
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AdaptiveExpenditureEngine.swift
@@ -0,0 +1,170 @@
+import Foundation
+
+/// One day's inputs for the retrospective expenditure estimate. Both fields are optional because the two
+/// series are independently sparse — a day with a weigh-in and no food log is common, and inventing a
+/// value for the missing one is exactly what this engine must not do.
+public struct AdaptiveExpenditureDay: Equatable, Sendable {
+ public let day: String // "yyyy-MM-dd", the app's day key
+ public let caloriesIn: Double?
+ public let weightKg: Double?
+
+ public init(day: String, caloriesIn: Double? = nil, weightKg: Double? = nil) {
+ self.day = day; self.caloriesIn = caloriesIn; self.weightKg = weightKg
+ }
+}
+
+/// How much the estimate below deserves to be trusted. Deliberately coarse — the inputs do not support
+/// a percentage, and a number would imply a precision this method does not have.
+public enum AdaptiveExpenditureConfidence: String, Equatable, Sendable {
+ case building, moderate, high
+}
+
+/// A retrospective estimate of average daily energy expenditure, with an interval.
+///
+/// Never a single number: the method's error is dominated by things this engine cannot see (hydration
+/// swings, an under-logged weekend), so a bare figure would be the fabrication the rest of NOOP refuses
+/// to make. The interval is the honest output and the caller should render it as one.
+public struct AdaptiveExpenditureEstimate: Equatable, Sendable {
+ public let estimatedDailyKcal: Double
+ public let lowerKcal: Double
+ public let upperKcal: Double
+ public let meanIntakeKcal: Double
+ public let weightSlopeKgPerDay: Double
+ public let intakeDays: Int
+ public let weightReadings: Int
+ public let windowDays: Int
+ public let confidence: AdaptiveExpenditureConfidence
+
+ public init(estimatedDailyKcal: Double, lowerKcal: Double, upperKcal: Double, meanIntakeKcal: Double,
+ weightSlopeKgPerDay: Double, intakeDays: Int, weightReadings: Int, windowDays: Int,
+ confidence: AdaptiveExpenditureConfidence) {
+ self.estimatedDailyKcal = estimatedDailyKcal; self.lowerKcal = lowerKcal; self.upperKcal = upperKcal
+ self.meanIntakeKcal = meanIntakeKcal; self.weightSlopeKgPerDay = weightSlopeKgPerDay
+ self.intakeDays = intakeDays; self.weightReadings = weightReadings; self.windowDays = windowDays
+ self.confidence = confidence
+ }
+}
+
+/// Average daily expenditure inferred from logged intake and the weight trend (TDEE by energy balance).
+///
+/// `expenditure = intake − change in stored energy`, with the conventional 7,700 kcal per kg of body mass.
+/// The identity is exact; the inputs are not. Day-to-day weight is mostly water, and food logs are
+/// under-reported by a wide and person-specific margin, so this is meaningful only over weeks and only as
+/// a range.
+///
+/// DELIBERATELY NOT AN INPUT TO ANYTHING. It never feeds Charge, the calorie card, or the workout
+/// calorie estimate: those come from measured heart rate through Keytel, and quietly overwriting a
+/// measurement with an inference from a food diary would be a strictly worse number wearing the same
+/// label. This answers a question the user asks explicitly, and returns nil rather than guess.
+///
+/// Kotlin twin: `AdaptiveExpenditureEngine`.
+public enum AdaptiveExpenditureEngine {
+ /// Energy density of body-mass change. The textbook 7,700 kcal/kg (≈3,500 kcal/lb) figure, which
+ /// assumes the change is adipose; it over-states early loss, when a real share of it is glycogen and
+ /// its bound water. That bias is one reason the output is an interval.
+ public static let kcalPerKg = 7_700.0
+
+ /// Windows. Three weeks is the floor because a week of water retention can hide a 500 kcal/day gap,
+ /// and six weeks is the ceiling because beyond that the body's own expenditure has adapted and the
+ /// average stops describing today.
+ public static let minWindowDays = 21
+ public static let maxWindowDays = 42
+
+ /// Coverage floors. Intake is the weak input, so it carries the strictest gate: a fortnight of logs
+ /// AND 70% of the window, which together reject the common "logged hard for five days" pattern that
+ /// would otherwise read as a huge deficit.
+ public static let minIntakeDays = 14
+ public static let minIntakeCoverage = 0.70
+ public static let minWeightReadings = 6
+
+ /// nil when the history cannot support an estimate — the normal answer for most installs, and the
+ /// point of the gates. `days` need not be sorted or contiguous.
+ public static func estimate(days: [AdaptiveExpenditureDay]) -> AdaptiveExpenditureEstimate? {
+ let ordered = days.sorted { $0.day < $1.day }
+ guard let first = ordered.first?.day, let last = ordered.last?.day,
+ let span = dayCount(from: first, to: last), span >= minWindowDays else { return nil }
+ let window = min(span, maxWindowDays)
+ // Keep the most RECENT `window` days: an adapted metabolism makes the tail the honest part.
+ let recent = ordered.filter { d in
+ guard let back = dayCount(from: d.day, to: last) else { return false }
+ // `dayCount` is INCLUSIVE — `dayCount(last, last)` is 1 — so the last `window` days are
+ // `back <= window`. A strict `<` silently drops the oldest day while still reporting the
+ // full `window`, which both loses data and understates coverage.
+ return back <= window
+ }
+
+ let intake = recent.compactMap { $0.caloriesIn }.filter { $0 > 0 }
+ let weights = recent.compactMap { d -> (Int, Double)? in
+ guard let w = d.weightKg, w > 0, let i = dayCount(from: first, to: d.day) else { return nil }
+ return (i, w)
+ }
+ guard intake.count >= minIntakeDays,
+ min(1.0, Double(intake.count) / Double(window)) >= minIntakeCoverage,
+ Set(weights.map { $0.0 }).count >= minWeightReadings,
+ let slope = leastSquaresSlope(weights) else { return nil }
+
+ let meanIntake = intake.reduce(0, +) / Double(intake.count)
+ // The identity. A RISING weight means intake exceeded expenditure, so the stored-energy term is
+ // subtracted — getting this sign backwards is the classic error and it is why the test pins both
+ // directions rather than only a deficit.
+ let estimate = meanIntake - slope * kcalPerKg
+
+ // Interval. Half a kilo of water across the window is an everyday swing and translates directly
+ // into an apparent daily gap; the intake half widens as coverage falls, because the days someone
+ // fails to log are not a random sample of their eating.
+ let waterKcalPerDay = (0.5 * kcalPerKg) / Double(window)
+ // Clamped: `coverage` is "share of the window that was logged", so it cannot exceed 1. A caller
+ // that merged its two sparse series badly and passed a day twice would otherwise push it above 1,
+ // which SHRINKS the margin and RAISES the confidence — making the answer look more certain than
+ // its data, the one direction this engine must never err in. Clamping rather than de-duplicating
+ // on purpose: silently picking one of two conflicting values for a day would hide the caller's bug.
+ let coverage = min(1.0, Double(intake.count) / Double(window))
+ let intakeUncertainty = meanIntake * 0.10 * (1.0 - coverage) + meanIntake * 0.05
+ let margin = waterKcalPerDay + intakeUncertainty
+
+ // DISTINCT days, not readings. Two weigh-ins on one morning are one day of evidence about the
+ // trend, and counting both would let a chatty scale — or a caller that passed a day twice — buy
+ // the same confidence as a fortnight of extra data.
+ let weightDays = Set(weights.map { $0.0 }).count
+ let confidence: AdaptiveExpenditureConfidence
+ if window >= 28 && coverage >= 0.90 && weightDays >= 12 { confidence = .high }
+ else if window >= 21 && coverage >= 0.80 { confidence = .moderate }
+ else { confidence = .building }
+
+ return AdaptiveExpenditureEstimate(
+ estimatedDailyKcal: estimate,
+ lowerKcal: estimate - margin, upperKcal: estimate + margin,
+ meanIntakeKcal: meanIntake, weightSlopeKgPerDay: slope,
+ intakeDays: min(intake.count, window), weightReadings: weightDays, windowDays: window,
+ confidence: confidence)
+ }
+
+ /// Ordinary least-squares slope in kg per DAY. nil when every reading shares one day, which would
+ /// divide by zero — a real case when a scale syncs several readings with one timestamp.
+ static func leastSquaresSlope(_ points: [(Int, Double)]) -> Double? {
+ let n = Double(points.count)
+ guard n >= 2 else { return nil }
+ let meanX = points.reduce(0.0) { $0 + Double($1.0) } / n
+ let meanY = points.reduce(0.0) { $0 + $1.1 } / n
+ var num = 0.0, den = 0.0
+ for (x, y) in points {
+ let dx = Double(x) - meanX
+ num += dx * (y - meanY); den += dx * dx
+ }
+ guard den > 0 else { return nil }
+ return num / den
+ }
+
+ /// Whole days between two "yyyy-MM-dd" keys, inclusive of the first.
+ ///
+ /// nil on an unparseable key. `AnalyticsEngine.dayStartUtcSeconds` is deliberately nil-tolerant and
+ /// returns 0 for one (so a bad key cannot take down a scoring pass), which is indistinguishable from
+ /// 1970 here — and a stray 0 would silently stretch the window by twenty thousand days. Treating a
+ /// zero as unparseable is the difference between refusing to answer and answering nonsense.
+ static func dayCount(from: String, to: String) -> Int? {
+ let a = AnalyticsEngine.dayStartUtcSeconds(from)
+ let b = AnalyticsEngine.dayStartUtcSeconds(to)
+ guard a > 0, b > 0 else { return nil }
+ return (b - a) / 86_400 + 1
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AlarmReadback.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AlarmReadback.swift
new file mode 100644
index 0000000000..42b760ed58
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AlarmReadback.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+/// Whether the alarm time a strap reports back can be compared with the one we sent (#1706).
+///
+/// The two halves are persisted under flat, device-less keys — `alarm.lastArmSentEpoch` and
+/// `alarm.lastReportedEpoch`. On an install with more than one strap registered they can therefore
+/// describe DIFFERENT devices, and comparing them then produces a confident "strap didn't accept the
+/// time" about a strap that was never asked. On Android the readback is written only on the WHOOP 4.0
+/// path, so a 5.0-active install could only ever be comparing across straps.
+///
+/// That verdict is not cosmetic here: it also drives `alarm.rejectStreak`, which raises a warning in
+/// SmartAlarmView at two. A cross-strap comparison could climb that streak forever.
+///
+/// So attribution is required, not assumed: unless both halves are known to come from the same strap,
+/// this refuses to judge. Refusing is the same stance `WindowedStreamPlan` takes — a diagnosis that
+/// cannot be proven is worse than none, because it sends the reader after the wrong device.
+///
+/// Twin of Kotlin `AlarmReadback`.
+public enum AlarmReadback {
+
+ /// Seconds of slack allowed between what we armed and what the strap reports back.
+ public static let toleranceS = 120
+
+ public enum Verdict: Equatable {
+ /// Same strap, and the readback agrees within `toleranceS`.
+ case matches
+ /// Same strap, and it does not. This is the only value that means the strap refused.
+ case mismatch
+ /// The two halves came from different straps. Nothing can be concluded about either.
+ case differentStrap
+ /// One or both halves predate device attribution, so they cannot be tied to a strap.
+ case unattributed
+ }
+
+ public static func verdict(
+ sentEpoch: Int,
+ reportedEpoch: Int,
+ sentDeviceId: String?,
+ reportedDeviceId: String?,
+ toleranceS: Int = AlarmReadback.toleranceS
+ ) -> Verdict {
+ guard let sentDeviceId, !sentDeviceId.isEmpty,
+ let reportedDeviceId, !reportedDeviceId.isEmpty else { return .unattributed }
+ guard sentDeviceId == reportedDeviceId else { return .differentStrap }
+ return abs(reportedEpoch - sentEpoch) > toleranceS ? .mismatch : .matches
+ }
+
+ /// The suffix the debug export appends after the reported time. Byte-identical to the Kotlin twin.
+ public static func suffix(_ verdict: Verdict) -> String {
+ switch verdict {
+ case .matches: return " ✓ matches"
+ case .mismatch: return " ⚠️ MISMATCH — strap didn't accept the time"
+ case .differentStrap: return " (readback is from a different strap — not comparable)"
+ case .unattributed: return " (no strap recorded for one of these — not comparable)"
+ }
+ }
+
+ /// Whether this verdict may advance the consecutive-rejection streak. Only a proven same-strap
+ /// disagreement counts: an unattributed or cross-strap reading must leave the streak untouched
+ /// rather than reset it, since neither is evidence either way.
+ public static func countsAsRejection(_ verdict: Verdict) -> Bool { verdict == .mismatch }
+
+ /// Whether this verdict is evidence the strap DID accept, which clears the streak.
+ public static func clearsRejectionStreak(_ verdict: Verdict) -> Bool { verdict == .matches }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift
index b7026d65c9..15edf923f9 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift
@@ -175,6 +175,13 @@ public enum AnalyticsEngine {
/// from `dayString`), and the Kotlin mirror degrades the SAME way (`runCatching { … }.getOrDefault(0)`)
/// instead of throwing, so a single bad day key can never take down a whole scoring pass on either
/// platform (nil-tolerant over fail-fast, per the #996 review).
+ ///
+ /// This is a UTC midnight that callers pair with one captured offset and fixed 86,400-second blocks,
+ /// so every boundary beyond a clock change sits an hour from the local midnight it names.
+ /// `LocalDayWindows` is the zone-rule-correct primitive built to replace that, and is deliberately
+ /// called by nothing yet. This function remains the shipped answer until a switch-over lands; the two
+ /// disagree by an hour on the far side of a transition, and `LocalDayWindowsTests` pins both answers
+ /// so the difference is visible rather than discovered.
static func dayStartUtcSeconds(_ day: String) -> Int {
Int(isoDay.date(from: day)?.timeIntervalSince1970 ?? 0)
}
@@ -293,6 +300,10 @@ public enum AnalyticsEngine {
public static let vendorRespMinSpanS = 3_600
public static func analyzeDay(day: String,
+ // Optional sink for the Effort funnel line. Nil (the default) builds
+ // nothing at all — see StrainScorer.strain. A parameter rather than a
+ // field so this engine stays pure.
+ strainDiag: ((String) -> Void)? = nil,
hr: [HRSample] = [],
rr: [RRInterval] = [],
resp: [RespSample] = [],
@@ -502,11 +513,20 @@ public enum AnalyticsEngine {
} else {
let rrSorted = rr.sortedByTsStable()
let enrichedProvided: [SleepSession] = providedSleep.map { s in
+ // #1884: no HR-only special case any more. This used to short-circuit on `s.hrOnly` to
+ // preserve #1801's display-only guarantee, which withheld both values. Now that an HR-only
+ // session reports what it measured, that clause is not merely redundant — an HR-only night
+ // that measured a resting HR but no HRV (no R-R banked) would take the short-circuit and
+ // skip the fill every other session gets. The rule is uniform: fill what is missing.
guard s.restingHR == nil || s.avgHRV == nil else { return s }
let rhr = s.restingHR ?? SleepStager.sessionRestingHR(start: s.start, end: s.end, hr: hr)
let hrv = s.avgHRV ?? SleepStager.sessionAvgHRV(start: s.start, end: s.end, rr: rrSorted)
+ // `hrOnly` carried explicitly: unlike Kotlin's `copy`, this rebuilds the struct field by
+ // field, so a new flag is dropped by DEFAULT unless named here. #1884 removed the guard
+ // that used to keep HR-only nights away from this line, so this is now the only thing
+ // standing between the flag and silent loss rather than a belt.
return SleepSession(start: s.start, end: s.end, efficiency: s.efficiency,
- stages: s.stages, restingHR: rhr, avgHRV: hrv)
+ stages: s.stages, restingHR: rhr, avgHRV: hrv, hrOnly: s.hrOnly)
}
let keptDetected = refinedSessions.filter { d in
!enrichedProvided.contains { $0.start < d.end && d.start < $0.end }
@@ -546,17 +566,36 @@ public enum AnalyticsEngine {
var deepS = 0.0, remS = 0.0, lightS = 0.0, tstS = 0.0
var inBedS = 0.0, effWeighted = 0.0
var disturbances = 0
+ // Hypnogram COVERAGE across the group: how much of the fragments' own spans the stage segments
+ // actually account for. Accumulated separately from `inBedS` because that one later absorbs the
+ // inter-fragment gap (#777/#705), which is a different quantity — a bridged out-of-bed gap is
+ // known-awake time between two fragments, whereas a hole INSIDE a fragment is time we never
+ // observed at all. Summed straight off `s.stages` (no JSON re-parse: the segments are already
+ // decoded here), then handed to `HypnogramCoverage` so the ratio/clamp/nil rules have ONE
+ // definition shared with the merge-side guard.
+ // NOTE on scope at THIS call site: coverage here is summed off the DECODED segments, not off
+ // `stagesJSON`, so the timestamp-free shapes are not screened out by the payload-shape rule the
+ // merge side uses. A minute-dict session decodes to zero segments and contributes span with no
+ // cover, and what keeps it from reading as a holed night is `fraction`'s `coveredSeconds > 0`
+ // returning nil for a group with no timestamped stages at all. Same outcome, different
+ // mechanism — and it holds only while a group is single-sourced, which the day merge ensures by
+ // choosing one side. A group mixing a timestamped fragment with a minute-dict one would read as
+ // holed; `HypnogramCoverageTests.testMixedSourceGroupReadsAsHoled` pins both halves.
+ var coveredS = 0.0, spanS = 0.0
for s in mainGroup {
let m = SleepStager.hypnogramMetrics(s)
let inBed = Double(s.end - s.start)
inBedS += inBed // each fragment's own in-bed span (the gap is added below)
effWeighted += s.efficiency * inBed // in-bed-weighted efficiency across the group
+ spanS += inBed
+ for seg in s.stages where seg.end > seg.start { coveredS += Double(seg.end - seg.start) }
deepS += m.deepMin * 60.0
remS += m.remMin * 60.0
lightS += m.lightMin * 60.0
tstS += m.tstS
disturbances += m.disturbances
}
+ let stageCoverage = HypnogramCoverage.fraction(coveredSeconds: coveredS, spanSeconds: spanS)
// OUT-OF-BED time BETWEEN bridged fragments is AWAKE (#777/#705): a main night bridged from two
// fragments split by a 20-min wake gap was reporting that gap as nowhere (it is in no fragment's
// [start,end) span), so 20+ min of real awake read as ~4 min - a v7.1 regression, multi-reporter.
@@ -642,7 +681,25 @@ public enum AnalyticsEngine {
// negligible shift. The Rest/sleep-quality term is main-night; the recovery physiology is
// day-best-resting, night-dominated. Keep these two definitions distinct on purpose.
// Daily resting HR = lowest per-session resting HR across matched sessions.
- let restingHRDaily = matched.compactMap { $0.restingHR }.min()
+ // #1801/#1884: the sessions whose PHYSIOLOGY is folded into the day's aggregates. Motion-backed
+ // sessions are PREFERRED; an HR-only night is used only when the day has no other kind.
+ //
+ // #1801 excluded HR-only nights outright, reasoning that a baseline is the one thing a false positive
+ // cannot be unwound from. #1884 narrowed that rather than reversing it: only the session BOUNDS are
+ // inferred from heart rate — each RMSSD is measured over its own 5-minute window — so excluding the
+ // night discarded a real 22-25ms HRV and left Charge with NO input instead of a slightly fuzzy one, on
+ // every scoring pass in the field log. Preferring keeps the original protection exactly where it earned its keep (a mixed
+ // day still ignores the HR-only night outright) and gives it up only where the alternative was
+ // nothing at all. The night still travels marked `hrOnly` for any consumer that wants to weigh it
+ // down; what it no longer gets is a silent delete.
+ //
+ // Named once rather than filtered at each use: the deep-window HRV pool and the SDNN index below
+ // re-derive from `rr` over each session's own stages instead of reading restingHR/avgHRV, so the
+ // only way to scope them is through the session set itself — which is precisely the "one forgotten
+ // call site" a scattered filter invites.
+ let physiologyOnly = matched.filter { !$0.hrOnly }
+ let physiologySessions = physiologyOnly.isEmpty ? matched : physiologyOnly
+ let restingHRDaily = physiologySessions.compactMap { $0.restingHR }.min()
// Daily avg HRV = in-bed-weighted mean of per-session avg HRV.
let avgHRVDaily: Double? = {
if deepHrvWindow {
@@ -652,13 +709,13 @@ public enum AnalyticsEngine {
// = successive diffs). nil when no deep sleep is detected (WHOOP-4.0 staging can be sparse) —
// the caller shows calibrating, never a fabricated number.
let rrSorted = rr.sortedByTsStable()
- let deep = matched.flatMap { s in
+ let deep = physiologySessions.flatMap { s in
SleepStager.sessionHrvWindows(start: s.start, end: s.end, rr: rrSorted, stages: s.stages)
.filter { $0.stage == "deep" }.compactMap { $0.rmssd }
}
return deep.isEmpty ? nil : deep.reduce(0, +) / Double(deep.count)
}
- let pairs = matched.compactMap { s -> (Double, Double)? in
+ let pairs = physiologySessions.compactMap { s -> (Double, Double)? in
s.avgHRV.map { ($0, Double(s.end - s.start)) }
}
guard !pairs.isEmpty else { return nil }
@@ -675,7 +732,7 @@ public enum AnalyticsEngine {
// against a watch meaningless. The 5-min index is window-comparable to those. nil when no segment has
// enough clean beats (HRVAnalyzer's own gate). Keeps the R-R timestamps (segmentation needs them).
let avgSDNNDaily: Double? = {
- let inBed = rr.filter { r in matched.contains { r.ts >= $0.start && r.ts < $0.end } }
+ let inBed = rr.filter { r in physiologySessions.contains { r.ts >= $0.start && r.ts < $0.end } }
return inBed.isEmpty ? nil : HRVAnalyzer.sdnnIndex(inBed, segmentSec: 300)
}()
@@ -801,8 +858,12 @@ public enum AnalyticsEngine {
// night `hr` for pure-function callers/tests.
let effMaxHR: Double? = maxHROverride ?? (profile.age > 0 ? StrainScorer.tanakaHRmax(age: profile.age) : nil)
let restForStrain = restingHRDaily.map(Double.init) ?? StrainScorer.defaultRestingHR
+ // The Effort ring's own funnel. A nil sink builds nothing at all, so a caller that does not want
+ // diagnostics pays nothing; IntelligenceEngine passes its per-day recorder, the same one the
+ // `workout detect` and `sleep-detect` lines beside it already use.
let strain = StrainScorer.strain(dayHr ?? hr, maxHR: effMaxHR, restingHR: restForStrain,
- method: effortMethod, sex: profile.sex)
+ method: effortMethod, sex: profile.sex,
+ diag: strainDiag, day: day)
// ── Workouts ──────────────────────────────────────────────────────────
// Detect over the full CALENDAR day (dayHr/dayGravity) when the caller supplies it, so a
@@ -904,7 +965,17 @@ public enum AnalyticsEngine {
// The ABSOLUTE this pass's deviation was derived from (#1636). Set HERE, beside
// `skinTempDevC`, so the engine's own row is symmetric: any path that persists this
// struct directly keeps both thermal values, not just the one.
- skinTempC: nightlySkinTempC)
+ skinTempC: nightlySkinTempC,
+ // "Every session this day was staged from heart rate alone."
+ //
+ // #1884: read from the SESSIONS' own marker, NOT from `physiologySessions.isEmpty`. Those two
+ // were equivalent while the set was `matched` minus the HR-only ones, so an all-HR-only night
+ // emptied it. They are NOT equivalent now that the set FALLS BACK to `matched`: it can never
+ // be empty when `matched` is not, which would have pinned this flag to false forever and
+ // silently retired the #1879 note. Deriving it from `hrOnly` states what the flag has always
+ // meant and is independent of how the physiology set is chosen. Byte-identical twin of Kotlin
+ // `AnalyticsEngine`'s `sleepHrOnly = if (matched.isEmpty()) null else matched.all { it.hrOnly }`.
+ sleepHrOnly: matched.isEmpty ? nil : matched.allSatisfy { $0.hrOnly })
_ = sleepStart; _ = sleepEnd // available for callers wiring sleep_start/end columns
// ── Cache rows ────────────────────────────────────────────────────────
@@ -952,11 +1023,14 @@ public enum AnalyticsEngine {
let effortConfidence = ScoreConfidence.effort(strain: strain, hrSampleCount: hr.count)
// Rest confidence with H9: downgrade a high-efficiency night whose deep+REM share is implausibly low
// to low-confidence (likely staging miss) — honest, no faked stages. tstS/efficiency are the
- // main-group totals computed above; restorative = deepS + remS.
+ // main-group totals computed above; restorative = deepS + remS. `stageCoverage` adds the third
+ // guard: a night whose stage timeline covers only part of its own span (an incompletely-received
+ // device hypnogram) cannot earn a SOLID Rest either, and neither of the other two can see it.
let restConfidence = ScoreConfidence.rest(hasSession: !matched.isEmpty,
hasStagedSleep: hasStagedSleep,
asleepSeconds: tstS, restorativeSeconds: deepS + remS,
- efficiency: efficiency, gravitySparse: gravitySparse)
+ efficiency: efficiency, gravitySparse: gravitySparse,
+ stageCoverage: stageCoverage)
return DayResult(daily: daily, sleepSessions: matched, cachedSleep: cachedSleep,
workouts: workouts, recovery: recovery, strain: strain,
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift
index 8654ee8bd1..58f9c45b28 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift
@@ -31,11 +31,21 @@ public enum AnalyzeRecentDayCache {
/// both a 4.0 and a 5/MG): when a day's resolved owner flips between straps, keying on the owner makes
/// the reuse invalidate **explicitly**, rather than relying on two different devices never producing an
/// identical `count`+`maxTs` for the same window.
+ /// - `streams`: the opaque per-day witness of every OTHER scored stream — PPG-derived HR, R-R,
+ /// respiration, SpO2, gravity, steps, skin temp and events (`WhoopStore.dayStreamFingerprint`).
+ /// #29: a history offload does not commit its channels together, and an offloaded HR row duplicating
+ /// a live one is dropped by `ON CONFLICT DO NOTHING`, so a night can be scored from HR alone and then
+ /// gain its R-R with `hrCount`/`hrMaxTs` completely unmoved. Keyed on HR alone this said "reuse", and
+ /// the HRV-less scan was re-served for the rest of the session — a force refresh included, since
+ /// `force` only bypasses the whole-pass watermark gate. The whole-pass gate had already been widened
+ /// to every stream (`analysisFingerprint`, v2); this is the same widening at day granularity, which is
+ /// where the reuse decision is actually made.
///
/// Inputs that feed `analyzeDay` but are pass-global rather than per-day (profile, baselines1, sleep
/// need / consistency, habitual midsleep, tz, stager toggles) are NOT in this key — the engine drops the
/// whole cache when its pass config signature changes, which covers them.
public static func cacheKey(owner: String, hrCount: Int, hrMaxTs: Int, skinAnchorRaw: Double?,
+ streams: String,
// #1575: whether this day is the one that emits the PER-WINDOW HRV detail
// (`dayStart == nowLocalMidnight`). Now that an active trace no longer
// disables reuse, this has to invalidate: the night cached as "today" with
@@ -47,6 +57,6 @@ public enum AnalyzeRecentDayCache {
// a trace mode is on.
hrvWindowDetail: Bool) -> String {
let anchor = skinAnchorRaw.map { String($0.bitPattern) } ?? "nil"
- return "\(owner)|\(hrCount):\(hrMaxTs):\(anchor):\(hrvWindowDetail ? "d" : "s")"
+ return "\(owner)|\(hrCount):\(hrMaxTs):\(anchor):\(hrvWindowDetail ? "d" : "s")|\(streams)"
}
}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift
index d88027646f..ff4fa689cd 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Baselines.swift
@@ -279,6 +279,29 @@ public enum Baselines {
/// from your strap for N days" when the baseline aged out silently instead of only "building your
/// baseline". Pure and TZ-free (civil-day arithmetic); mirror EXACTLY in the Kotlin twin.
/// `dayKeys`/`nightlyHrv` are parallel (same night per index); `today` is an ISO `yyyy-MM-dd` key.
+ /// Nights the app OBSERVED in the recent window, and how many of them carried no usable HRV.
+ ///
+ /// Distinct from both neighbours, and the gap between them. `calibrationNights` counts progress
+ /// toward a baseline; `nightsSinceNewestValidNight` measures a TOTAL drought and only speaks once it
+ /// passes `staleDays`. Neither says anything about the common case: nights arriving, but most of them
+ /// carrying nothing. A wearer five days in with three empty nights sits at "2 of 4" with no reason
+ /// given, which reads as a stuck counter rather than as missing data — that is the report this exists
+ /// to answer.
+ ///
+ /// Counts only days the app has a row for, so a new install is not charged for nights before it owned
+ /// the strap. Pure and TZ-free (civil-day arithmetic); mirror EXACTLY in the Kotlin twin.
+ public static func recentHrvCoverage(dayKeys: [String], nightlyHrv: [Double?], today: String,
+ window: Int = staleDays) -> (observed: Int, missing: Int) {
+ guard let t = isoEpochDay(today) else { return (0, 0) }
+ var observed = 0, missing = 0
+ for i in 0..= 0, t - d < window else { continue }
+ observed += 1
+ if nightlyHrv[i] == nil { missing += 1 }
+ }
+ return (observed, missing)
+ }
+
public static func nightsSinceNewestValidNight(dayKeys: [String], nightlyHrv: [Double?], today: String) -> Int? {
var newest: String? = nil
for i in 0..,
+ controlDays: Set,
outcomeByDay: [String: Double],
behavior: String,
outcome: String) -> BehaviorEffect? {
@@ -91,7 +105,10 @@ public enum BehaviorInsights {
var withoutVals: [Double] = []
for (day, value) in outcomeByDay {
if behaviorDays.contains(day) { withVals.append(value) }
- else { withoutVals.append(value) }
+ else if controlDays.contains(day) { withoutVals.append(value) }
+ // Neither: the behaviour was not logged that day. Not a control — no information.
+ // A day in BOTH would count as "with"; callers merge imported ∪ native before building
+ // these, so one question cannot hold two answers for one day.
}
let n1 = withVals.count
@@ -125,11 +142,16 @@ public enum BehaviorInsights {
/// return them sorted by |cohensD| descending, with significant effects first.
/// Behaviors that don't yield a computable effect are dropped.
public static func rank(behaviors: [String: Set],
+ controls: [String: Set],
outcomeByDay: [String: Double],
outcome: String) -> [BehaviorEffect] {
var effects: [BehaviorEffect] = []
for (name, days) in behaviors {
- if let e = effect(behaviorDays: days, outcomeByDay: outcomeByDay,
+ // A behaviour absent from `controls` has no controls and yields nothing — failing CLOSED, so
+ // a caller that forgets loses the insight rather than getting a wrong one measured against
+ // every day the user never opened the journal.
+ if let e = effect(behaviorDays: days, controlDays: controls[name] ?? [],
+ outcomeByDay: outcomeByDay,
behavior: name, outcome: outcome) {
effects.append(e)
}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift
index 2f97eb368f..350874cd73 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CaptureCompleteness.swift
@@ -72,7 +72,10 @@ public enum CaptureCompleteness {
.dataImport: ["import stage=", "rowsIn="],
.steps: ["stepsRaw", "stepsCal"],
.battery: ["bank soc=", "socSeries"],
- .recovery: ["charge term", "charge score=", "charge nilScore"],
+ // "charge day=" for the same reason as the Kotlin twin: IntelligenceEngine re-emits every
+ // recovery trace line as `charge day= ` + the body, so none of the three variants this
+ // used to name could ever match and the domain reported MISSING on every capture.
+ .recovery: ["charge day="],
.hrv: ["hrv rmssd=", "hrv result=", "hrv nightSummary"],
.universal: ["dayOwner ", "strapClock "],
]
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift
index f6278a2388..1e9e90e450 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CircadianEngine.swift
@@ -34,12 +34,28 @@ public enum CircadianEngine {
/// so a low-resting wearer faces a LOWER absolute bar, not a higher one — the opposite of the concern
/// raised in #982, which assumed the gate penalises the fittest.
///
- /// Deliberately NOT re-tuned to an absolute bpm floor. The shape is arguably wrong for this domain,
- /// but every candidate value is unvalidated, nobody is currently silenced by it, and the direction of
- /// the harm is not established (it needs a wearer whose amplitude is disproportionately small for
- /// their mesor — an n=1 observation). `CircadianEngineTests` pins what the gate costs at each mesor so
- /// the trade is visible to whoever does have the data to change it.
+ /// The VALUE is still not re-tuned — every candidate remains unvalidated. What changed is the SHAPE:
+ /// `minAbsoluteAmplitudeBpm` now passes a swing large enough in bpm regardless of mesor. This note
+ /// asked for "a wearer whose amplitude is disproportionately small for their mesor — an n=1
+ /// observation" before that could happen, and one arrived: 5.5 bpm on a 74.7 bpm mesor with a coherent
+ /// acrophase. "Nobody is currently silenced by it" was true when written and is no longer.
+ /// `CircadianEngineTests` pins what the gate costs at each mesor so the trade stays visible.
public static let minRelativeAmplitude: Double = 0.10
+ /// Absolute amplitude (bpm) that reads as rhythmic whatever the mesor — the relative bar's escape hatch.
+ ///
+ /// The relative test scales the requirement WITH resting HR, so the identical swing is accepted on one
+ /// body and refused on another: 5.5 bpm passes at a 55 bpm mesor and fails at 74.7. That is not a
+ /// judgement about the rhythm, it is a judgement about the baseline, and a measured wearer sat exactly
+ /// there — 5.5 bpm on a 74.7 bpm mesor, acrophase 16.1 h implying a CBTmin near 04:06, which is a
+ /// textbook phase rather than noise. `minRelativeAmplitude`'s own note called for precisely that
+ /// observation ("a wearer whose amplitude is disproportionately small for their mesor") before the
+ /// shape could be revisited.
+ ///
+ /// 4.5 bpm is NOT a new tuning constant: it is the absolute amplitude the relative gate ALREADY
+ /// accepts at the bottom of the ~45-75 bpm mesor range it was described against. The rule this
+ /// encodes is internal consistency — an amplitude good enough for some wearer is good enough for all
+ /// — so the change is strictly more permissive and no one who reads rhythmic today stops.
+ public static let minAbsoluteAmplitudeBpm: Double = 4.5
/// Max clock-shift the planner steps per day (hours) — the well-established ~1 h/day re-entrainment rate.
public static let maxShiftPerDayHours: Double = 1.0
/// CBTmin sits roughly this many hours before habitual wake; used to translate the activity acrophase
@@ -48,6 +64,20 @@ public enum CircadianEngine {
/// Activity acrophase (peak activity) sits roughly this many hours after CBTmin in a typical day — the
/// offset used to convert the cosinor acrophase into an estimated temperature-minimum time.
public static let acrophaseAfterCbtMinHours: Double = 12.0
+ /// Population-typical wake hour, used ONLY to place the chronotype reference point.
+ ///
+ /// `offsetVsScheduleMinutes` compares the clock to the USER'S OWN schedule, so it cannot name a
+ /// chronotype: someone reliably asleep 03:00-11:00 has an offset near zero and would read
+ /// "intermediate" while being strongly evening-type. A named lean needs an ABSOLUTE phase, so it is
+ /// bucketed from `tempMinHour` against a population reference instead.
+ public static let chronotypeReferenceWakeHour: Double = 7.0
+ /// Half-width of the "intermediate" band around the reference CBTmin, in hours.
+ ///
+ /// Deliberately wide. `tempMinHour` is derived from an activity cosinor (`acrophase - 12 h`) unless a
+ /// measured `observedTempMinHour` is supplied, and NO production caller supplies one today — so this
+ /// is a lean inferred from movement, not a thermal measurement. A one-hour band either side keeps the
+ /// three buckets coarse enough to be honest about that.
+ public static let chronotypeBandHours: Double = 1.0
// MARK: - Inputs
@@ -180,7 +210,10 @@ public enum CircadianEngine {
guard let fit = cosinor(bins) else { return nil }
let relativeAmplitude = fit.mesor != 0 ? fit.amplitude / abs(fit.mesor) : 0
- if daysObserved < minDaysForFit || relativeAmplitude < minRelativeAmplitude {
+ // Rhythmic on EITHER measure: a proportional swing, or an absolute one large enough to read on
+ // any baseline. See `minAbsoluteAmplitudeBpm` for why the relative test alone was self-inconsistent.
+ let rhythmic = relativeAmplitude >= minRelativeAmplitude || fit.amplitude >= minAbsoluteAmplitudeBpm
+ if daysObserved < minDaysForFit || !rhythmic {
// A reading is returned, but flagged unreadable so the surface says "hard to read right now."
let tmin = observedTempMinHour ?? wrap24(fit.acrophaseHours - acrophaseAfterCbtMinHours)
return PhaseEstimate(tempMinHour: tmin, acrophaseHours: fit.acrophaseHours,
@@ -198,7 +231,13 @@ public enum CircadianEngine {
let offsetHours = signedHourDelta(from: idealTempMin, to: tempMinHour)
let offsetMinutes = offsetHours * 60.0
- let confidence: PhaseConfidence = daysObserved >= goodDaysForFit ? .solid : .wide
+ // SOLID means strong on BOTH axes, not just enough days. A rhythm admitted by
+ // `minAbsoluteAmplitudeBpm` alone is real but modest, and a smaller swing pins its acrophase less
+ // tightly — so it stays `.wide`. That matters because `.wide` is what withholds `chronotype`,
+ // which names a category off exactly that acrophase: widening the readable gate should give more
+ // people a body clock, not give a thinner fit a firmer label.
+ let confidence: PhaseConfidence =
+ (daysObserved >= goodDaysForFit && relativeAmplitude >= minRelativeAmplitude) ? .solid : .wide
let lean: String
if offsetMinutes > 20 { lean = "later (a night-owl lean)" }
else if offsetMinutes < -20 { lean = "earlier (a morning-lark lean)" }
@@ -322,6 +361,86 @@ public enum CircadianEngine {
return x
}
+ /// A coarse, ABSOLUTE body-clock category: where the temperature minimum sits on the clock.
+ ///
+ /// NOT the "chronotype lean" wording used elsewhere. `estimatePhase` builds a `lean` string from
+ /// `offsetVsScheduleMinutes` ("a night-owl lean"), and the v5 skin-temp design spec defines chronotype
+ /// lean as "earlier/later than your sleep schedule implies" — both RELATIVE to the wearer's own
+ /// schedule. This is relative to the CLOCK, and the two genuinely disagree: a consistent 03:00-11:00
+ /// sleeper is well-aligned by the relative read and `.evening` by this one. Keep the vocabularies
+ /// disjoint in anything user-facing, or the two readings look like a contradiction (#1409).
+ ///
+ /// Three buckets, not a score: the underlying phase estimate is an activity fit, and a finer grain
+ /// would imply precision it does not have.
+ public enum Chronotype: String, Equatable, Sendable, Codable {
+ case morning // CBTmin earlier than the population reference
+ case intermediate
+ case evening // CBTmin later than the population reference
+ }
+
+ /// The reference CBTmin clock hour a lean is measured against: the population wake hour minus the
+ /// same `cbtMinBeforeWakeHours` the phase estimator already uses, so the anchor moves with the
+ /// engine's own model rather than being a second, independently-drifting constant.
+ public static var chronotypeAnchorHour: Double {
+ wrap24(chronotypeReferenceWakeHour - cbtMinBeforeWakeHours)
+ }
+
+ /// The chronotype-ideal sleep window for a night of `durationHours`, as clock hours.
+ ///
+ /// Anchored on the temperature minimum: a well-entrained sleeper wakes about `cbtMinBeforeWakeHours`
+ /// after CBTmin, so the ideal wake is `tempMinHour + cbtMinBeforeWakeHours` and the ideal bedtime is
+ /// that minus the night's own length.
+ ///
+ /// USING THE ACTUAL DURATION IS THE POINT. Giving the ideal arc the same length as the real one makes
+ /// the comparison purely about PHASE — did you sleep at the right TIME — so a short night reads as
+ /// aligned-but-short rather than as misaligned. Feeding a "needed" duration instead would fold two
+ /// different failures into one arc and make a debt look like a body-clock problem.
+ ///
+ /// nil for a non-positive or impossible duration; a window longer than a day cannot be placed on a
+ /// 24 h ring, and silently wrapping it would draw a full circle that means nothing.
+ public static func idealSleepWindow(tempMinHour: Double,
+ durationHours: Double) -> (bedHour: Double, wakeHour: Double)? {
+ guard durationHours > 0, durationHours < 24 else { return nil }
+ let wake = wrap24(tempMinHour + cbtMinBeforeWakeHours)
+ return (bedHour: wrap24(wake - durationHours), wakeHour: wake)
+ }
+
+ /// Signed hours the ACTUAL sleep window sits later (+) or earlier (−) than the chronotype-ideal one.
+ ///
+ /// Deliberately NOT `offsetVsScheduleMinutes`. That field compares the body clock to the wearer's own
+ /// SCHEDULE; this compares the night actually slept to where the CLOCK wanted it. A dial that draws an
+ /// actual arc against an ideal arc must caption itself with the distance between those two arcs, or
+ /// the number contradicts the picture — the two disagree exactly when someone keeps a consistent
+ /// schedule that does not suit their clock, which is the case the dial exists to show.
+ ///
+ /// Anchored on wake rather than bedtime because `idealSleepWindow` builds the ideal window from the
+ /// wake end; comparing bedtimes would fold the night's DURATION into a phase reading.
+ public static func sleepWindowOffsetHours(tempMinHour: Double, actualWakeHour: Double) -> Double {
+ signedHourDelta(from: wrap24(tempMinHour + cbtMinBeforeWakeHours), to: wrap24(actualWakeHour))
+ }
+
+ /// Bucket an ABSOLUTE temperature-minimum clock hour into a lean.
+ ///
+ /// Compared CIRCULARLY. A `tempMinHour` of 23:30 is five hours BEFORE the 04:30 anchor — a strong
+ /// morning lean — and a naive `23.5 > 5.5` would call it evening instead. Pure, so the boundaries are
+ /// assertable without building a fit.
+ public static func chronotype(tempMinHour: Double) -> Chronotype {
+ let delta = signedHourDelta(from: chronotypeAnchorHour, to: wrap24(tempMinHour))
+ if delta < -chronotypeBandHours { return .morning }
+ if delta > chronotypeBandHours { return .evening }
+ return .intermediate
+ }
+
+ /// The lean for a phase estimate, or nil when the fit is not strong enough to name one.
+ ///
+ /// Gated to `.solid` on purpose. `.wide` is a real fit on thin data and is fine for the continuous
+ /// offset the card already shows, but a NAMED category reads as a fact about the person rather than a
+ /// reading of the week, so it waits for the stronger tier. `.unreadable` never names one.
+ public static func chronotype(_ estimate: PhaseEstimate) -> Chronotype? {
+ guard estimate.confidence == .solid else { return nil }
+ return chronotype(tempMinHour: estimate.tempMinHour)
+ }
+
/// Signed shortest delta in hours from `a` to `b` on the 24 h clock, in (−12, 12].
static func signedHourDelta(from a: Double, to b: Double) -> Double {
var d = (b - a).truncatingRemainder(dividingBy: 24.0)
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ClockFormat.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ClockFormat.swift
new file mode 100644
index 0000000000..07e1ef1457
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ClockFormat.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/// #1821: which clock the UI shows times in. Twin of the Kotlin `ClockFormatPreference`.
+///
+/// NOOP had no such setting: every user-facing time came from the device REGION's convention. On Apple
+/// `AppLanguage.activeLocale` builds `language_REGION` deliberately, to keep regional date order and
+/// clock style - but constructing a locale from a region identifier DISCARDS the user's explicit
+/// "24-Hour Time" switch, because that override lives on `Locale.autoupdatingCurrent`, not on the region
+/// default. A reader in a 24-hour region who prefers 12-hour had no way to say so, which is the report
+/// this exists to answer.
+public enum ClockFormatPreference: String, CaseIterable, Sendable {
+ /// Follow the device's own clock switch. The default, so nobody's display changes on upgrade.
+ case system
+ case twelveHour
+ case twentyFourHour
+
+ /// Persistence key, shared by the Apple `@AppStorage` binding and the Android preference so the two
+ /// platforms cannot drift apart on the name of the thing they store.
+ public static let defaultsKey = "clockFormatPreference"
+
+ /// Unknown/absent stored values fall back to `.system` rather than to a hardcoded clock: a value we
+ /// cannot parse must not silently pin every reader to one format.
+ public static func from(stored: String?) -> ClockFormatPreference {
+ guard let stored, let parsed = ClockFormatPreference(rawValue: stored) else { return .system }
+ return parsed
+ }
+}
+
+public enum ClockFormat {
+ /// Resolve the preference against what the device reports. `systemUses24Hour` is injected rather than
+ /// read here so this stays pure and identically testable on both platforms - Apple derives it from
+ /// `Locale.autoupdatingCurrent` (which DOES carry the 24-Hour Time switch), Android from
+ /// `DateFormat.is24HourFormat(context)`.
+ public static func uses24Hour(preference: ClockFormatPreference, systemUses24Hour: Bool) -> Bool {
+ switch preference {
+ case .system: return systemUses24Hour
+ case .twelveHour: return false
+ case .twentyFourHour: return true
+ }
+ }
+
+ /// Literal wall-clock pattern at minute precision, for formatters driven by an explicit pattern.
+ /// Used by Android, whose existing time labels already build `SimpleDateFormat` from a literal.
+ public static func hourMinutePattern(uses24Hour: Bool) -> String {
+ uses24Hour ? "HH:mm" : "h:mm a"
+ }
+
+ /// A locale identifier carrying an explicit HOUR CYCLE, so a formatter driven by `timeStyle` or by a
+ /// `j`-bearing template resolves the clock the reader chose instead of the region default.
+ ///
+ /// This is what lets the setting reach the ~17 Apple formatters that never mention "HH:mm" at all -
+ /// they say `timeStyle = .short` or template `"jmm"`, and both ask the LOCALE for the hour. Rewriting
+ /// each into an explicit pattern would have thrown away localized date order, separators and AM/PM
+ /// wording; setting the hour cycle keeps all of it and changes only the part the reader asked for.
+ ///
+ /// `h23`/`h12` are the ICU `hours` keyword values. A malformed identifier would silently fall back to
+ /// the base locale - a no-op that looks like a working setting - so `ClockFormatLocaleTests` asserts
+ /// against a real `DateFormatter` on macOS rather than trusting the string.
+ public static func hourCycleLocaleIdentifier(base: String, uses24Hour: Bool) -> String {
+ "\(base)@hours=\(uses24Hour ? "h23" : "h12")"
+ }
+
+ /// SKELETON template for the same time, for `setLocalizedDateFormatFromTemplate`. Apple uses this
+ /// rather than the literal above so the locale still decides ordering, separator and AM/PM wording,
+ /// while the H-vs-h choice - the only part the reader asked to control - stays ours. Note this is
+ /// deliberately NOT the `j` template the buggy path used: `j` resolves the hour from the locale, which
+ /// is exactly how a reader's explicit preference got discarded.
+ public static func hourMinuteTemplate(uses24Hour: Bool) -> String {
+ uses24Hour ? "Hmm" : "hmm"
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CoachSuggestions.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CoachSuggestions.swift
new file mode 100644
index 0000000000..3199f5b397
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CoachSuggestions.swift
@@ -0,0 +1,102 @@
+import Foundation
+import WhoopStore
+
+// CoachSuggestions.swift — contextual coaching prompt chips derived from the user's own bands.
+//
+// Pure + deterministic so it is unit-testable without a strap, network, or app target, and so the
+// output can be byte-identical to the Android twin `com.noop.analytics.CoachSuggestions` (the
+// cross-platform parity contract: the same inputs produce the same chip strings on both platforms).
+// Reads only DailyMetric fields that already live on-device; no new egress, no telemetry.
+
+/// Contextual suggestion chips for the Coach composer, derived from today's bands.
+public enum CoachSuggestions {
+
+ /// The stable generic set used when there is no usable data for today. Byte-identical to the
+ /// Android `FALLBACK` list.
+ public static let fallback: [String] = [
+ "How's my recovery trending this week?",
+ "What should today's training look like?",
+ "Analyse my sleep",
+ "Why am I run down?",
+ ]
+
+ /// A stable generic prompt always appended as the last chip so there is a consistent entry point
+ /// even when the contextual chips already cover the user's situation.
+ private static let stableGeneric = "Analyse my sleep"
+
+ /// Charge band cutoffs (mirrors the autoregulation bands in `AICoachEngine.defaultSystemPrompt`).
+ private static let chargeRedCutoff: Double = 34
+ private static let chargeGreenCutoff: Double = 67
+ /// HRV "trending down" threshold: today's HRV below 85% of the trailing-30-day baseline (excluding
+ /// today) flags a downward nudge. Matches the Android twin.
+ private static let hrvDownRatio: Double = 0.85
+ /// Sleep "poor night" cutoff: under 6h (360 min).
+ private static let poorSleepMin: Double = 360
+ /// "Already loaded" strain cutoff: a day strain at/above 14 reads as a high-load day.
+ private static let highStrain: Double = 14
+ /// Max chips surfaced.
+ private static let maxChips: Int = 4
+
+ /// Build 2–4 contextual prompt chips from today's bands, falling back to `fallback` when there is
+ /// no usable data. `recent` is oldest→newest and is used for the trailing-30-day HRV baseline.
+ ///
+ /// - Parameters:
+ /// - today: today's (or the newest scored) `DailyMetric`; nil when no data is available.
+ /// - recent: recent days oldest→newest, for the HRV baseline. May be empty or may include today.
+ /// - Returns: 2–4 chip strings (contextual) or the 4-string `fallback`.
+ public static func suggestions(for today: DailyMetric?, recent: [DailyMetric]) -> [String] {
+ guard let today else { return fallback }
+ let charge = today.recovery
+ let hrv = today.avgHrv
+ let sleep = today.totalSleepMin
+ let strain = today.strain
+ // No usable signal at all → generic fallback.
+ guard charge != nil || hrv != nil || sleep != nil || strain != nil else { return fallback }
+
+ var chips: [String] = []
+
+ // 1. Charge band → one readiness prescription chip.
+ if let c = charge {
+ if c < chargeRedCutoff {
+ chips.append("Active recovery only today — what should I do?")
+ } else if c < chargeGreenCutoff {
+ chips.append("Quality over volume today — plan my session")
+ } else {
+ chips.append("Green light — how hard can I push today?")
+ }
+ }
+
+ // 2. HRV trending down vs trailing-30-day baseline (excluding today).
+ if let h = hrv {
+ let baseline = hrvBaseline(recent, excluding: today.day)
+ if let avg = baseline, avg > 0, h < hrvDownRatio * avg {
+ chips.append("Why is my HRV trending down?")
+ }
+ }
+
+ // 3. Poor sleep (< 6h).
+ if let s = sleep, s < poorSleepMin {
+ chips.append("I slept poorly — how do I recover today?")
+ }
+
+ // 4. Already a high-strain day.
+ if let st = strain, st >= highStrain {
+ chips.append("Have I done enough today, or push more?")
+ }
+
+ // Signals present but none of the band conditions fired → generic fallback (avoids a lone
+ // stable chip when there's nothing contextual to say).
+ guard !chips.isEmpty else { return fallback }
+ // Cap the CONTEXTUAL chips at maxChips−1 so the stable generic always survives as the last
+ // chip (a consistent entry point), then append it. Total never exceeds maxChips.
+ return Array(chips.prefix(maxChips - 1)) + [stableGeneric]
+ }
+
+ /// Mean of `avgHrv` over the trailing 30 days of `recent`, excluding the day `excludingDay`.
+ /// Returns nil when no qualifying night has an HRV value. Pure; byte-twin of the Android helper.
+ private static func hrvBaseline(_ recent: [DailyMetric], excluding day: String) -> Double? {
+ let values = recent.suffix(30).filter { $0.day != day }.compactMap { $0.avgHrv }
+ guard !values.isEmpty else { return nil }
+ return values.reduce(0, +) / Double(values.count)
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift
index 91d554dc5e..fdbc332703 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift
@@ -227,23 +227,129 @@ public enum ConnectionReadout {
if let d = deviceClockUnix {
return d < ConnectionTrace.rtcEpochCeilingUnix ? "no (RTC reads 1970/71)" : "yes"
}
+ // #1823: this branch has NOT read a clock. It is reached when no clock correlation exists - which
+ // is every 5/MG, whose GET_CLOCK reply rides the puffin notify chars and never touches the WHOOP4
+ // correlation path - so the only evidence is how the strap DATED its banked records. Saying "RTC
+ // reads 1970/71" there claimed a reading we never took, in the one readout a reporter quotes when
+ // asking why nothing syncs. Report the evidence we actually have.
if let n = strapNewestUnix {
- return n < ConnectionTrace.rtcEpochCeilingUnix ? "no (RTC reads 1970/71)" : "yes"
+ return n < ConnectionTrace.rtcEpochCeilingUnix ? "no (records dated 1970/71)" : "yes"
}
return "no (waiting for the strap clock)"
}
+ /// #1818: at or above this charge the "charge it" remedy is already satisfied, so repeating it is
+ /// noise. Twin of the Kotlin constant - the two must move together or the platforms give
+ /// different advice for the same strap.
+ public static let rtcAlreadyChargedPct: Double = 95
+
/// #987: a plain-words warning when the strap RTC reads epoch-era (~1970/71), from EITHER signal we
/// hold: the correlated device clock or the strap's newest banked-record timestamp. nil when both
/// look sane (or neither was seen yet - we never fabricate a fault). One string, shown verbatim on
/// the Devices / Test Centre connection readout, naming the consequence and the fix.
- public static func rtcWarning(deviceClockUnix: Int?, strapNewestUnix: Int?) -> String? {
+ ///
+ /// #1818: the remedy is battery-dependent. A flat battery resets the RTC, so on a low strap
+ /// "charge it" is real advice. On an ALREADY-charged strap it is not, and the old copy sent users
+ /// at 100% round a loop they had already run.
+ ///
+ /// The charged branch deliberately states only what holds for EVERY strap - that charging again
+ /// will not change it - and asks for a log. It must NOT claim NOOP re-sends the clock on every
+ /// connect: that is true on WHOOP4 (`runConnectHandshake` calls SET_CLOCK unconditionally, both
+ /// payload forms, #120) but FALSE on a 5/MG, where the clock write is gated behind `didBond`, and
+ /// an unbondable 5/MG (#1635) is never clocked at all - precisely the strap most likely to be
+ /// showing this warning. Explaining the mechanism in the sentence is how the original bug happened.
+ /// `batteryPct` nil (not yet read) keeps the charge advice - we only withdraw it on evidence.
+ /// Callers MUST pass a reading from the CURRENT link (iOS: `batterySamples.last?.soc`, which is
+ /// cleared on disconnect) and not a last-known charge that outlives it - a stale 100% would
+ /// suppress the advice in the one case it is right, a strap that ran flat and reset its RTC.
+ public static func rtcWarning(deviceClockUnix: Int?, strapNewestUnix: Int?,
+ batteryPct: Double? = nil) -> String? {
let ceiling = ConnectionTrace.rtcEpochCeilingUnix
let clockBad = deviceClockUnix.map { $0 > 0 && $0 < ceiling } ?? false
let newestBad = strapNewestUnix.map { $0 > 0 && $0 < ceiling } ?? false
guard clockBad || newestBad else { return nil }
- return "Strap clock reads 1970/71 (never set since its last reset), so it is not banking history. "
- + "Charge the strap to 100% and reconnect so the clock latches."
+ let lead = "Strap clock reads 1970/71 (never set since its last reset), so it is not banking history. "
+ if let batteryPct, batteryPct >= rtcAlreadyChargedPct {
+ return lead
+ + "The strap is already charged, so charging again will not change this. Export a strap "
+ + "log from Test Centre so the clock exchange can be read."
+ }
+ return lead + "Charge the strap to 100% and reconnect so the clock latches."
+ }
+
+ /// #1809: one-line account of a finished BLE link, logged on every disconnect.
+ ///
+ /// A strap log could not previously answer "did the strap send anything?". Inbound notifications only
+ /// stamped a liveness timestamp that was then discarded, and the disconnect error reached the log as
+ /// the OS `localizedDescription` while the `CBError` code the #617 branch computes was thrown away. A
+ /// reporter chasing a silent strap had to infer silence from the fact that every LOGGED line happened
+ /// to be outgoing - which measures NOOP's logging, not the strap. This measures the strap.
+ ///
+ /// `armed` matters because the #80 marginal-radio fallback only counts a drop when the R10/R11 burst
+ /// was actually armed; `armed=no` says up front that the detector cannot trip for this link, however
+ /// many times the loop repeats.
+ ///
+ /// Milliseconds are printed raw: no float formatting, so the two platforms cannot round apart.
+ public static func linkEpitaph(upMillis: Int, inboundFrames: Int, inboundBytes: Int,
+ cmdChannelFrames: Int, realtimeArmed: Bool, ended: String) -> String {
+ let armed = realtimeArmed ? "yes" : "no"
+ var line = "Link epitaph: up \(max(0, upMillis))ms, inbound \(max(0, inboundFrames)) frames / "
+ + "\(max(0, inboundBytes)) bytes (cmd-channel \(max(0, cmdChannelFrames))), "
+ + "realtime armed=\(armed), ended=\(ended)"
+ if inboundFrames <= 0 {
+ line += " - the strap sent NOTHING on this link"
+ }
+ return line
+ }
+
+ /// #1635: what a finished link actually stored, split by PATH.
+ ///
+ /// The epitaph counts frames, which is right for a silent strap and wrong for one talking over only
+ /// part of its surface: an unbonded 5/MG streams heart rate and R-R all night while nothing
+ /// bond-gated lands, and the epitaph reports hundreds of healthy inbound frames.
+ ///
+ /// The split must be by PATH, not by stream. The realtime decoder produces only hr, rr, events and
+ /// battery; gravity, respiratory, skin temperature, SpO2 and steps arrive solely through the
+ /// historical decoder behind the offload. A live-only tally therefore printed "nothing banked live
+ /// for: gravity, resp, …" on EVERY link, bonded or not — a constant dressed as a finding.
+ ///
+ /// The offload is where the bond shows: an unbonded strap defers backfill entirely, so `offload none`
+ /// is the real signal and a healthy sync fills it.
+ ///
+ /// Battery is absent on purpose — it rides the standard 0x2A19 profile and banks with or without the
+ /// bond. `offloadSteps` is optional: a platform that cannot measure a stream omits it rather than
+ /// reporting a zero that reads as a fault. Counts are rows ACCEPTED. Pure, total and clamped.
+ /// Twin of the Kotlin formatter.
+ public static func linkBankedSummary(liveHr: Int, liveRr: Int, offloadChunks: Int,
+ offloadHr: Int, offloadRr: Int, offloadGravity: Int,
+ offloadResp: Int, offloadSkinTemp: Int, offloadSpo2: Int,
+ offloadSteps: Int?) -> String {
+ let live = "live hr=\(max(0, liveHr)) rr=\(max(0, liveRr))"
+ var raw: [(String, Int)] = [
+ ("hr", offloadHr), ("rr", offloadRr), ("gravity", offloadGravity), ("resp", offloadResp),
+ ("skinTemp", offloadSkinTemp), ("spo2", offloadSpo2),
+ ]
+ if let offloadSteps { raw.append(("steps", offloadSteps)) }
+ let offload = raw.map { ($0.0, max(0, $0.1)) }
+ let total = offload.reduce(0) { $0 + $1.1 }
+ // Three states, reported as FACTS rather than verdicts. "No chunks" is not evidence of a fault on
+ // its own: a short or command-only link never reaches backfill, and the reason is already logged
+ // beside it. The epitaph supplies the uptime a reader needs to weigh it.
+ //
+ // "Never ran" and "ran with nothing new" are still DIFFERENT. Rows are counted as ACCEPTED, so
+ // a reconnect re-offloading already-stored records banks zero while the strap plainly handed its
+ // history over. Only the first case speaks to the bond.
+ if (max(0, offloadChunks)) == 0 {
+ return "banked this link: \(live) | offload none"
+ }
+ if total == 0 {
+ return "banked this link: \(live) | offload ran \(max(0, offloadChunks)) chunk(s), no new rows"
+ }
+ let body = offload.map { "\($0.0)=\($0.1)" }.joined(separator: " ")
+ let empty = offload.filter { $0.1 == 0 }.map { $0.0 }
+ if empty.isEmpty { return "banked this link: \(live) | offload \(body)" }
+ return "banked this link: \(live) | offload \(body)"
+ + " - nothing banked from the offload for: \(empty.joined(separator: ", "))"
}
/// #987: freshness label for the "last frame" readout row: how long ago the most recent strap frame
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DayCycle.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DayCycle.swift
new file mode 100644
index 0000000000..a2218230f0
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DayCycle.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+public enum DayCycleMode: String, CaseIterable, Sendable {
+ case sleepOnset = "sleep_onset"
+ case midnight = "midnight"
+
+ public static let storageKey = "noop.dayCycleMode"
+ /// Kotlin twin: `DayCycleMode.fromPersisted`.
+ public static func persisted(_ value: String?) -> DayCycleMode {
+ value.flatMap(Self.init(rawValue:)) ?? .sleepOnset
+ }
+}
+
+public struct DayCycleWindow: Equatable, Sendable {
+ public enum Source: Equatable, Sendable { case detectedSleep, editedSleep, syntheticMidnight, calendar }
+ public let id: String
+ public let startInclusive: Int
+ public let endExclusive: Int
+ public let displayDay: String
+ public let source: Source
+
+ public init(id: String, startInclusive: Int, endExclusive: Int, displayDay: String, source: Source) {
+ self.id = id; self.startInclusive = startInclusive; self.endExclusive = endExclusive
+ self.displayDay = displayDay; self.source = source
+ }
+}
+
+public enum DayCycleResolver {
+ public static let minSyntheticMidnightAgeSeconds = 18 * 3_600
+ public static let absoluteMaxOpenSeconds = 40 * 3_600
+
+ /// Kotlin twin: `DayCycleResolver.calendarWindow`.
+ public static func calendarWindow(now: Int, offsetSec: Int) -> DayCycleWindow {
+ let local = now + offsetSec
+ let dayNumber = Int(floor(Double(local) / Double(SleepStageTotals.secondsPerDay)))
+ let start = dayNumber * SleepStageTotals.secondsPerDay - offsetSec
+ let day = AnalyticsEngine.dayString(start, offsetSec: offsetSec)
+ return DayCycleWindow(id: "calendar:\(day)", startInclusive: start, endExclusive: now,
+ displayDay: day, source: .calendar)
+ }
+
+ /// Kotlin twin: `DayCycleResolver.fallbackMidnightAfter`.
+ public static func fallbackMidnight(after start: Int, offsetSec: Int) -> Int {
+ let minimum = start + minSyntheticMidnightAgeSeconds
+ let local = minimum + offsetSec
+ let dayNumber = Int(floor(Double(local) / Double(SleepStageTotals.secondsPerDay)))
+ let midnight = dayNumber * SleepStageTotals.secondsPerDay - offsetSec
+ return midnight >= minimum ? midnight : (dayNumber + 1) * SleepStageTotals.secondsPerDay - offsetSec
+ }
+
+ /// Kotlin twin: `DayCycleResolver.activeWindow`.
+ public static func activeWindow(mode: DayCycleMode, latestSleep: DayCycleWindow?, now: Int,
+ offsetSec: Int) -> DayCycleWindow {
+ guard mode == .sleepOnset, let latestSleep else { return calendarWindow(now: now, offsetSec: offsetSec) }
+ let age = now - latestSleep.startInclusive
+ let fallback = fallbackMidnight(after: latestSleep.startInclusive, offsetSec: offsetSec)
+ // Sleep-onset mode stays anchored across midnight unconditionally: only the absolute safety cap
+ // may synthesize a fallback boundary. An earlier design gated this on whether awake coverage was
+ // reliable and carried a `reliableAwakeCoverage` parameter for it; the gate was dropped but the
+ // parameter survived, unread on both platforms and passed `false` by every one of its five call
+ // sites. Removed rather than left looking like a switch someone could flip.
+ guard age < absoluteMaxOpenSeconds else {
+ let day = AnalyticsEngine.dayString(fallback, offsetSec: offsetSec)
+ return DayCycleWindow(id: "synthetic:\(day)", startInclusive: fallback, endExclusive: now,
+ displayDay: day, source: .syntheticMidnight)
+ }
+ return DayCycleWindow(id: latestSleep.id, startInclusive: latestSleep.startInclusive,
+ endExclusive: now, displayDay: latestSleep.displayDay, source: latestSleep.source)
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift
index 0ff4645c04..aad7f5068b 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/EffectRanker.swift
@@ -104,13 +104,18 @@ public enum EffectRanker {
/// significant-first, |cohensD| desc, then behaviour name asc. Behaviours with no
/// computable lag are dropped.
public static func rank(behaviors: [String: Set],
+ controls: [String: Set],
outcomeByDay: [String: Double],
outcome: String) -> [RankedEffect] {
var rows: [RankedEffect] = []
// Sort behaviour names so the build order is deterministic regardless of dict order.
for name in behaviors.keys.sorted() {
let days = behaviors[name]!
- if let row = bestLag(behaviorDays: days, outcomeByDay: outcomeByDay,
+ // Absent from `controls` means no control group, which yields nothing — see
+ // BehaviorInsights.effect. Failing closed is deliberate: a caller that forgets loses the
+ // insight rather than getting one measured against every unlogged day.
+ if let row = bestLag(behaviorDays: days, controlDays: controls[name] ?? [],
+ outcomeByDay: outcomeByDay,
behavior: name, outcome: outcome) {
rows.append(row)
}
@@ -121,6 +126,7 @@ public enum EffectRanker {
/// Find the best-lag RankedEffect for ONE behaviour against ONE outcome, or nil when no
/// lag in `lagSet` yields a computable effect that clears the group gate.
public static func bestLag(behaviorDays: Set,
+ controlDays: Set,
outcomeByDay: [String: Double],
behavior: String,
outcome: String) -> RankedEffect? {
@@ -128,6 +134,7 @@ public enum EffectRanker {
for lag in lagSet {
let shifted = shiftedOutcome(outcomeByDay, byLag: lag)
guard let e = BehaviorInsights.effect(behaviorDays: behaviorDays,
+ controlDays: controlDays,
outcomeByDay: shifted,
behavior: behavior,
outcome: outcome) else { continue }
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift
index 99eb32fbc3..fc1c66671c 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift
@@ -424,10 +424,11 @@ public enum HRVAnalyzer {
/// arriving in real time, carrying no timestamps to measure coverage with — and suppressing those
/// would refuse honest readings, the opposite of the point.
///
- /// Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here. Their dominant
- /// error on a banked stream was the lost within-second emission order (#823, root-caused in #1072),
- /// which is fixed at the write path; whether they need a gate of their own is a question for a
- /// post-fix capture to answer, not an assumption to bake in now.
+ /// Successive-difference statistics (RMSSD, pNN50) are not gated HERE, but they are no longer ungated
+ /// by COVERAGE: see `successiveDiffIsTrustworthy` below. The question this comment left open was one for
+ /// a post-fix capture to answer; that capture arrived and answered it yes. Note it answered THIS
+ /// question only. The sibling deferral on `beatValuesAreTrustworthy`, about beat-to-beat accuracy, is
+ /// still open and still waiting on a capture of its own.
public static func beatSpreadIsTrustworthy(_ verdict: RrCoverageVerdict) -> Bool {
switch verdict {
case .sameSecondOverCount, .crossSecondOverCount:
@@ -437,6 +438,51 @@ public enum HRVAnalyzer {
}
}
+ /// Whether RMSSD / pNN50 can be trusted from a window with this coverage verdict (#1118).
+ ///
+ /// The sibling of `beatSpreadIsTrustworthy`, and the same two verdicts refuse, but for its own reason
+ /// rather than by borrowing that one's. SDNN is a spread over every interval, so a duplicated beat
+ /// widens it. Successive-difference statistics fail the other way: a beat the heart never produced
+ /// sits next to its neighbour with a near-zero difference, and RMSSD is the root mean square of
+ /// exactly those differences, so an over-counted night reads LOW. Measured on a WHOOP 4.0 night whose
+ /// banked R-R covers 2.13× the wall clock it spans, RMSSD reads 36 ms where the same night's beats
+ /// collapsed read 41 ms; the direction of the error is not obvious from the number, which is precisely
+ /// why it cannot be shown.
+ ///
+ /// The comment on `beatSpreadIsTrustworthy` used to say these were deliberately ungated because their
+ /// dominant error was the lost within-second emission order fixed at the write path (#823 / #1072),
+ /// and that whether they needed a gate of their own was for a post-fix capture to answer. The capture
+ /// arrived: a build carrying that fix, on a WHOOP 4.0, still reports `crossSecondOverCount` on every
+ /// measured night, with 91% of the inflating beat-time on seconds written by more than one delivery.
+ /// The write-path fix did not make the stream clean, so the assumption it licensed no longer holds.
+ ///
+ /// WITHHOLD, DO NOT FABRICATE. This trades a wrong number for a blank on every affected night, which
+ /// is a real cost: HRV is the dominant Charge driver, so a gated night drops that term and renormalises
+ /// the rest. That cost is accepted deliberately. A contaminated RMSSD does not merely display wrong, it
+ /// enters the personal baseline the following nights are scored against, so showing it spreads the
+ /// error into days whose own capture was clean.
+ ///
+ /// THE COST REACHES FURTHER THAN THE CARD, and a reader should know it before widening this rule.
+ /// `Baselines.update` holds on a nil night without advancing `nValid`, so on an install whose EVERY
+ /// night gates, the HRV baseline never reaches `Baselines.minNightsSeed` and stays calibrating
+ /// indefinitely. That is the WHOOP 4.0-only case exactly. It is still the right answer, because the
+ /// alternative is seeding a personal baseline from beats the heart did not produce and scoring every
+ /// later night against it, but it means this gate can leave a strap with no HRV baseline at all rather
+ /// than merely a gap in one. A mixed install is unaffected: its clean nights seed the baseline and the
+ /// gated ones skip-and-hold.
+ ///
+ /// `underCovered` and `unmeasurable` stay trusted for the same reason they do above: neither duplicates
+ /// a beat, and `unmeasurable` is what an honest live spot reading looks like. Pure. Byte-parity twin of
+ /// Kotlin `successiveDiffIsTrustworthy`.
+ public static func successiveDiffIsTrustworthy(_ verdict: RrCoverageVerdict) -> Bool {
+ switch verdict {
+ case .sameSecondOverCount, .crossSecondOverCount:
+ return false
+ case .plausible, .underCovered, .unmeasurable:
+ return true
+ }
+ }
+
/// Whether a live spot capture collected more beat time than the wall clock allows (no per-beat
/// timestamps for `rrCoverage`, but the capture knows how long it ran). Only over-count rejects;
/// sparse windows stay with the `minBeats` gate. Pure. Byte-parity twin of Kotlin
@@ -499,8 +545,12 @@ public enum HRVAnalyzer {
/// median, so no per-beat artifact rule can see the fault — it is in the decomposition, not in
/// outliers.
///
- /// Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here, for the same
- /// reason they are not gated by the coverage verdict: that is a question for a capture to answer.
+ /// Successive-difference statistics (RMSSD, pNN50) are deliberately NOT gated here. They ARE now gated
+ /// on the coverage verdict (`successiveDiffIsTrustworthy`), but that is a different fault and a
+ /// different population, and the capture that justified it says nothing about this one. Gating here
+ /// would sweep in the very night described above: coverage 1.03, verdict plausible, and untrustworthy
+ /// only in its decomposition. Refusing those needs its own capture, and inventing one from a WHOOP 4.0
+ /// over-count would be exactly the assumption the paragraph above refused to bake in.
public static func beatValuesAreTrustworthy(beatAccurateFraction: Double) -> Bool {
// Negated `<` so a NaN input lands on `true` — NaN means "not measured", and an unmeasured
// window must not be silently refused. Matches the NaN convention in `classifyCoverage`.
@@ -808,8 +858,21 @@ public enum HRVAnalyzer {
static func msToInt(_ ms: Double) -> Int { ms > 0 ? Int(ms + 0.5) : 0 }
/// Whole-percent, integer half-up, so both platforms round a tie the same way. 0 when `total` is 0.
+ ///
+ /// The arithmetic is widened to `Int64` because `deliveryHistogram` passes BEAT-TIME IN
+ /// MILLISECONDS summed over a whole night, not a row count. The numerator is `part * 200 + total`,
+ /// so with `part <= total` it needs more than 32 bits from `total >= 10,683,999` ms (2.97 h of
+ /// beat-time on multi-delivery seconds) and a real over-count night carries 3-4x that. The multiply
+ /// ALONE would survive to `part >= 10,737,419`; the `+ total` term is what brings the bound down, so
+ /// quoting the multiply's limit understates the exposure. `Int` is 64-bit on iOS/macOS so this
+ /// platform read correctly while the Kotlin twin wrapped; it is 32-bit on `arm64_32`, and
+ /// StrandAnalytics already builds for watchOS, so the width is stated rather than inherited.
+ ///
+ /// The wrap did not always land negative, which is the dangerous half: a 100% multi-share 8 h night
+ /// (`pct(28_800_000, 28_800_000)`) returned 25 in 32 bits - positive, plausible, and wrong by 75
+ /// points. A negative percentage was a symptom of this, never the test for it. Twin of Kotlin `pct`.
static func pct(_ part: Int, _ total: Int) -> Int {
- total > 0 ? (part * 200 + total) / (total * 2) : 0
+ total > 0 ? Int((Int64(part) * 200 + Int64(total)) / (Int64(total) * 2)) : 0
}
/// #1008: a compact, deterministic RAW-ROW sample of the beats around the DENSEST second, for the
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift
index 43fe3a2f3e..6c05ee5e37 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HeartRateRecovery.swift
@@ -82,13 +82,23 @@ public enum HeartRateRecovery {
private static func sustainedSeconds(atOrAbove threshold: Double, in samples: [HRSample]) -> Int {
guard samples.count >= 2 else { return 0 }
- var seconds = 0
+ var currentSeconds = 0
+ var longestSeconds = 0
for i in 0..<(samples.count - 1) {
let gap = samples[i + 1].ts - samples[i].ts
- guard gap > 0, gap <= maximumContinuousGapSeconds else { continue }
- if Double(samples[i].bpm) >= threshold { seconds += gap }
+ guard gap > 0 else { continue }
+ guard gap <= maximumContinuousGapSeconds else {
+ currentSeconds = 0
+ continue
+ }
+ guard Double(samples[i].bpm) >= threshold else {
+ currentSeconds = 0
+ continue
+ }
+ currentSeconds += gap
+ longestSeconds = max(longestSeconds, currentSeconds)
}
- return seconds
+ return longestSeconds
}
private static func median(_ values: [Int]) -> Int? {
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift
new file mode 100644
index 0000000000..d4e9cc9b38
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift
@@ -0,0 +1,137 @@
+import Foundation
+
+/// Maps the app's persistence-relevant lifecycle edges onto the reason carried through Collector logs.
+/// The app and tests call this same seam, so lifecycle routing is exercised without UIKit on Linux.
+public enum StandardHRLifecycleFlush {
+ public enum Event {
+ case background
+ case termination
+ }
+
+ public static func run(
+ event: Event,
+ flush: (LivePersistTrace.StandardHRFlushReason) async -> Void
+ ) async {
+ switch event {
+ case .background:
+ await flush(.background)
+ case .termination:
+ await flush(.termination)
+ }
+ }
+}
+
+/// A live HR/R-R batch failed to persist and was re-buffered.
+///
+/// `Collector` re-queues the frames and swallows the error, so a store rejecting every insert produces a
+/// log full of `rr emit … offered=N` and no indication that none of it landed. That is the worst shape a
+/// diagnostic gap can take: the instrumentation that exists reads like success.
+///
+/// Twin of the Kotlin `liveInsertFailedLine` in `com.noop.ble.StalledLinkDiagnostics`, so an Android and
+/// an Apple log of the same failure compare directly. Rendered strings match for ASCII (store errors).
+/// The 200-character bound is not a Unicode-identical truncation: Kotlin `take(200)` counts UTF-16 code
+/// units and Swift `prefix(200)` counts grapheme clusters. Store error descriptions are ASCII, which is
+/// the load-bearing case. The Kotlin side lives beside its caller in the BLE package; this one lives here
+/// because `Collector` is app-target Swift with no default CI, and the package is where the other
+/// emitted-line builders (`Spo2ReTrace`, `SleepStager+Trace`, `ConnectionReadout`) already sit and get tested.
+public enum LivePersistTrace {
+
+ public enum StandardHRFlushReason: String {
+ case cadence
+ case disconnect
+ case background
+ case termination
+ case explicit
+ }
+
+ /// Bounded standard-HR transport-state diagnostics. These lines describe host observation and
+ /// buffer/persistence state only; they carry no physiological measurements and make no claim
+ /// about sensor-origin time or unobserved loss.
+ public static func standardHRHostReceivedLine(
+ hostUnixSeconds: Int,
+ acceptedHRRows: Int, acceptedRRRows: Int,
+ rejectedHRRows: Int, rejectedRRRows: Int,
+ pendingHRRows: Int, pendingRRRows: Int
+ ) -> String {
+ "standard-hr transport host-received hostUnixSec=\(hostUnixSeconds)"
+ + " acceptedHRRows=\(acceptedHRRows) acceptedRRRows=\(acceptedRRRows)"
+ + " rejectedHRRows=\(rejectedHRRows) rejectedRRRows=\(rejectedRRRows)"
+ + " pendingHRRows=\(pendingHRRows) pendingRRRows=\(pendingRRRows)"
+ }
+
+ public static func standardHRFlushAttemptLine(
+ reason: StandardHRFlushReason, offeredHRRows: Int, offeredRRRows: Int
+ ) -> String {
+ "standard-hr transport flush-attempt reason=\(reason.rawValue)"
+ + " offeredHRRows=\(offeredHRRows) offeredRRRows=\(offeredRRRows)"
+ }
+
+ public static func standardHRFlushSucceededLine(
+ reason: StandardHRFlushReason, offeredHRRows: Int, offeredRRRows: Int,
+ insertedHRRows: Int, insertedRRRows: Int
+ ) -> String {
+ "standard-hr transport flush-succeeded reason=\(reason.rawValue)"
+ + " offeredHRRows=\(offeredHRRows) offeredRRRows=\(offeredRRRows)"
+ + " insertedHRRows=\(insertedHRRows) insertedRRRows=\(insertedRRRows)"
+ }
+
+ public static func standardHRRebufferedForRetryLine(
+ reason: StandardHRFlushReason, attemptedHRRows: Int, attemptedRRRows: Int,
+ pendingHRRows: Int, pendingRRRows: Int, consecutiveFailures: Int
+ ) -> String {
+ "standard-hr transport rebuffered-for-retry reason=\(reason.rawValue)"
+ + " attemptedHRRows=\(attemptedHRRows) attemptedRRRows=\(attemptedRRRows)"
+ + " pendingHRRows=\(pendingHRRows) pendingRRRows=\(pendingRRRows)"
+ + " consecutiveFailures=\(consecutiveFailures)"
+ }
+
+ /// - Parameters:
+ /// - transport: which live path failed. There are TWO — the standard 0x2A37 reading and the puffin
+ /// REALTIME_DATA batch (#1118) — and they fail independently. A line that did not say which would
+ /// leave a reader unable to tell one dead transport from a dead store, the first fork in the
+ /// diagnosis.
+ /// - errorName: the error's type name. Spelled `throwableName` in the Kotlin twin; the rendered
+ /// line is identical.
+ /// - consecutiveFailures: matters more than any single error. One failure is the transient the
+ /// re-buffer exists to absorb; a climbing count is a store that will never accept these rows, and
+ /// only the count separates them.
+ public static func liveInsertFailedLine(
+ transport: String,
+ errorName: String,
+ message: String?,
+ hrFrames: Int,
+ rrFrames: Int,
+ consecutiveFailures: Int
+ ) -> String {
+ // Bounded like the Kotlin `take(200)`: a full error description can be enormous, and the useful
+ // cases (a full disk, a corrupted database, a schema mismatch) are distinguished well inside it.
+ // ASCII-only twin: `take` is UTF-16 code units, `prefix` is grapheme clusters.
+ let detail = message.flatMap { m -> String? in
+ let trimmed = m.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : ": " + String(m.prefix(200))
+ } ?? ""
+ let run = consecutiveFailures >= 2
+ ? " \(consecutiveFailures) consecutive failures — these rows are not landing and the re-buffer is"
+ + " not recovering them."
+ : " Re-buffered for the next cadence."
+ return "Live persist FAILED on \(transport) — \(errorName)\(detail) (hr=\(hrFrames) rr=\(rrFrames))."
+ + run
+ }
+
+ /// Rate limit for ``liveInsertFailedLine(transport:errorName:message:hrFrames:rrFrames:consecutiveFailures:)``.
+ ///
+ /// The live cadence is seconds, so an unconditional log would bury the rest of the capture under a
+ /// failure it has already reported. The gap is deliberately long: this line establishes THAT inserts
+ /// are failing and roughly for how long, which one line a minute answers as well as sixty.
+ ///
+ /// A zero `lastEmitMs` must emit — the first failure is the one most worth having. A BACKWARDS clock
+ /// emits too: wall time can step back, and comparing only forwards would strand `lastEmitMs` in the
+ /// future and silence the line until real time caught up. Byte-identical rule to the Kotlin twin.
+ public static func shouldEmitLiveInsertFailure(
+ lastEmitMs: Int64,
+ nowMs: Int64,
+ minGapMs: Int64 = 60_000
+ ) -> Bool {
+ lastEmitMs <= 0 || nowMs < lastEmitMs || nowMs - lastEmitMs >= minGapMs
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LocalDayWindows.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LocalDayWindows.swift
new file mode 100644
index 0000000000..fe4e1e1013
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LocalDayWindows.swift
@@ -0,0 +1,364 @@
+import Foundation
+
+/// A local calendar date as a year/month/day triple, mirroring the components of the Kotlin twin's
+/// `java.time.LocalDate`.
+///
+/// Swift has no calendar-date type that is free of a `Calendar`, and a `Calendar` is exactly what this
+/// helper must not lean on: the day-window rules in this file are stated explicitly so that a future
+/// change of library behaviour cannot move a day boundary silently. So the date is carried as its own
+/// value type and converted to and from a day number with proleptic-Gregorian arithmetic.
+public struct LocalCalendarDate: Hashable, Comparable, Sendable {
+
+ /// The proleptic Gregorian year, negative before year 1.
+ public let year: Int
+
+ /// The month, 1...12.
+ public let month: Int
+
+ /// The day of the month, 1...31. No validation happens here: an out-of-range day is normalised by
+ /// the day-number conversion, the same way `java.time` would reject it and callers here never
+ /// construct one.
+ public let day: Int
+
+ /// Build a date from its components.
+ public init(year: Int, month: Int, day: Int) {
+ self.year = year
+ self.month = month
+ self.day = day
+ }
+
+ /// Build the date that lies `daysSinceEpoch` days after 1970-01-01.
+ public init(daysSinceEpoch: Int) {
+ // Howard Hinnant's civil_from_days, which is exact over the whole Int range and needs no
+ // calendar object. Divisions truncate toward zero in Swift as they do in C, which is what the
+ // era shifts below are written for.
+ var z = daysSinceEpoch + 719_468
+ let era = (z >= 0 ? z : z - 146_096) / 146_097
+ z -= era * 146_097
+ let dayOfEra = z
+ let yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
+ let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
+ let monthPrime = (5 * dayOfYear + 2) / 153
+ let d = dayOfYear - (153 * monthPrime + 2) / 5 + 1
+ let m = monthPrime + (monthPrime < 10 ? 3 : -9)
+ let y = yearOfEra + era * 400 + (m <= 2 ? 1 : 0)
+ self.init(year: y, month: m, day: d)
+ }
+
+ /// The number of days from 1970-01-01 to this date, negative before it.
+ public var daysSinceEpoch: Int {
+ // Howard Hinnant's days_from_civil, the exact inverse of the initialiser above.
+ let y = year - (month <= 2 ? 1 : 0)
+ let era = (y >= 0 ? y : y - 399) / 400
+ let yearOfEra = y - era * 400
+ let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1
+ let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
+ return era * 146_097 + dayOfEra - 719_468
+ }
+
+ /// The date rendered as `YYYY-MM-DD`, which is also the day key this helper hands out.
+ public var key: String {
+ let y = year < 0 ? "-" + Self.pad(-year, 4) : Self.pad(year, 4)
+ return "\(y)-\(Self.pad(month, 2))-\(Self.pad(day, 2))"
+ }
+
+ /// The date `count` days after this one, negative counts moving backward.
+ public func adding(days count: Int) -> LocalCalendarDate {
+ LocalCalendarDate(daysSinceEpoch: daysSinceEpoch + count)
+ }
+
+ /// Order dates the way the calendar does.
+ public static func < (lhs: LocalCalendarDate, rhs: LocalCalendarDate) -> Bool {
+ if lhs.year != rhs.year { return lhs.year < rhs.year }
+ if lhs.month != rhs.month { return lhs.month < rhs.month }
+ return lhs.day < rhs.day
+ }
+
+ /// Left-pad a non-negative number with zeroes, without a formatter — the twin does the same, and a
+ /// locale-aware formatter is one more thing that could differ between the platforms.
+ private static func pad(_ value: Int, _ width: Int) -> String {
+ var s = String(value)
+ while s.count < width { s = "0" + s }
+ return s
+ }
+}
+
+/// One local day as a half-open window, with the UTC offset that was in effect when it began.
+public struct LocalDayWindow: Hashable, Sendable {
+
+ /// The date this window belongs to.
+ public let date: LocalCalendarDate
+
+ /// The first instant of the date.
+ public let start: Date
+
+ /// The start of the next date that exists in the zone, which is where this window ends. It is not
+ /// always `start` plus 24 hours, and where a calendar date is skipped entirely it is more than a
+ /// day away.
+ public let nextStart: Date
+
+ /// The UTC offset in effect at `start`, in seconds east of UTC.
+ ///
+ /// It describes the window's start only. A time of day anywhere else inside the window is derived
+ /// from the zone, never by adding this number, because a transition inside the window would make
+ /// that wrong by an hour.
+ public let utcOffsetSeconds: Int
+
+ /// Build a window from its parts.
+ public init(date: LocalCalendarDate, start: Date, nextStart: Date, utcOffsetSeconds: Int) {
+ self.date = date
+ self.start = start
+ self.nextStart = nextStart
+ self.utcOffsetSeconds = utcOffsetSeconds
+ }
+
+ /// How long the window lasts — 23, 24 or 25 hours in the fixture zones, and longer where the zone
+ /// skips a calendar date.
+ public var duration: TimeInterval {
+ nextStart.timeIntervalSince(start)
+ }
+}
+
+/// One entry of a backward run: a date that exists in the zone, with the start resolved for it.
+public struct LocalDayStart: Hashable, Sendable {
+
+ /// The date.
+ public let date: LocalCalendarDate
+
+ /// The first instant of that date in the zone.
+ public let start: Date
+
+ /// Build an entry from its parts.
+ public init(date: LocalCalendarDate, start: Date) {
+ self.date = date
+ self.start = start
+ }
+}
+
+/// When a local calendar date begins and ends in a time zone, taken from the zone's own rules.
+///
+/// The analysis core answers this question by adding fixed 86,400-second blocks to a midnight derived
+/// from a single UTC offset. That is wrong by an hour on every day boundary beyond a transition,
+/// because a day on which the clocks move is 23 or 25 hours long. This type is the shared primitive
+/// that answers it correctly; it changes no caller on its own.
+///
+/// SO TWO ANSWERS COEXIST, and this one is not yet the shipped one. `AnalyticsEngine.dayStartUtcSeconds`
+/// with `AnalyticsEngine.dayString` remains what every scored day actually uses; this file is called by
+/// nothing but its own tests until a switch-over lands. They disagree by an hour on the far side of a
+/// transition, which `LocalDayWindowsTests` pins deliberately by asserting BOTH answers for
+/// America/New_York on 2025-10-19. Reach for the core unless you are writing that switch-over.
+///
+/// Kotlin twin: `LocalDayWindows`.
+///
+/// Nothing here is inherited from a calendar: the ambiguous, skipped and non-existent cases are
+/// resolved by the rules stated on each operation, so that a future change in library behaviour is a
+/// change to this file rather than a silent change of a day boundary. The only thing asked of the
+/// platform is `TimeZone.secondsFromGMT(for:)`, the zone's raw rule lookup.
+///
+/// Both the zone and the reference instant are injectable and default to the named accessors
+/// `defaultTimeZone` and `defaultReferenceInstant`, so every rule below is provable without a device.
+public struct LocalDayWindows: Sendable {
+
+ /// The zone used when none is given: the auto-updating current zone, which follows a change of
+ /// device zone during the process's life. The Kotlin twin's default is the system default zone id,
+ /// which means the same thing.
+ ///
+ /// The zone is bound when the helper is constructed; a caller that must follow a device-zone change
+ /// during the process's life constructs a new helper.
+ public static var defaultTimeZone: TimeZone { TimeZone.autoupdatingCurrent }
+
+ /// The reference instant used when none is given: the current `Date`. The Kotlin twin's default is
+ /// the current `Instant`.
+ public static var defaultReferenceInstant: Date { Date() }
+
+ /// The zone whose rules every operation reads.
+ public let timeZone: TimeZone
+
+ /// The instant that stands for "now" — it clamps the sleep read window and nothing else. No start,
+ /// window, key or run depends on it.
+ public let referenceInstant: Date
+
+ /// Build a helper for a zone and a reference instant, both defaulting to the named accessors.
+ public init(timeZone: TimeZone = LocalDayWindows.defaultTimeZone,
+ referenceInstant: Date = LocalDayWindows.defaultReferenceInstant) {
+ self.timeZone = timeZone
+ self.referenceInstant = referenceInstant
+ }
+
+ /// The number of seconds either side of a local midnight that is probed for the offsets in effect
+ /// around it. Two days is far more than any transition needs and still far less than the gap
+ /// between two transitions anywhere in the database.
+ private static let probeWindow: Int64 = 2 * 86_400
+
+ /// The first instant that belongs to `date` in this zone, or `nil` when the zone has no instant on
+ /// that date at all.
+ ///
+ /// The rule, stated rather than inherited: where local midnight exists once, that is the start;
+ /// where it exists twice, the earlier of the two; where it does not exist, the first instant that
+ /// does exist on the date, which is the instant the clocks jumped to. Where that jump lands on a
+ /// later date the date itself never happened, and there is no start.
+ ///
+ /// The rule assumes at most one offset change within two days either side of the local midnight; no
+ /// zone in the database violates that today.
+ ///
+ /// The result does not depend on `referenceInstant`.
+ public func start(of date: LocalCalendarDate) -> Date? {
+ let localMidnight = Int64(date.daysSinceEpoch) * 86_400
+ let offsetBefore = offsetSeconds(atEpochSeconds: localMidnight - Self.probeWindow)
+ let offsetAfter = offsetSeconds(atEpochSeconds: localMidnight + Self.probeWindow)
+
+ var candidates = [localMidnight - Int64(offsetBefore), localMidnight - Int64(offsetAfter)].sorted()
+ if candidates.count == 2 && candidates[0] == candidates[1] { candidates.removeLast() }
+ // An instant maps to this local midnight exactly when reading it in the zone gives that wall
+ // time back. Ascending order means an ambiguous midnight yields the earlier instant first.
+ for candidate in candidates
+ where candidate + Int64(offsetSeconds(atEpochSeconds: candidate)) == localMidnight {
+ return Date(timeIntervalSince1970: TimeInterval(candidate))
+ }
+
+ // Local midnight fell in a gap. The first instant that exists on or after it is the instant the
+ // clocks jumped at; it belongs to this date only if it still reads as this date.
+ guard offsetBefore != offsetAfter else { return nil }
+ let jump = transitionEpochSeconds(around: localMidnight, offsetBefore: offsetBefore)
+ guard localDate(ofEpochSeconds: jump) == date else { return nil }
+ return Date(timeIntervalSince1970: TimeInterval(jump))
+ }
+
+ /// The half-open window `[start, nextStart)` of `date`, or `nil` when the date does not exist.
+ ///
+ /// `nextStart` is the start of the next date that *exists*, so a window that precedes a skipped
+ /// calendar date spans more than a day rather than ending at an instant that never happened.
+ public func window(of date: LocalCalendarDate) -> LocalDayWindow? {
+ guard let dayStart = start(of: date) else { return nil }
+ var next = date.adding(days: 1)
+ var nextStart: Date?
+ // A zone skipping more than a couple of consecutive dates has never happened; the bound keeps a
+ // corrupt zone from turning this into an unbounded walk.
+ for _ in 0..<8 {
+ if let candidate = start(of: next) {
+ nextStart = candidate
+ break
+ }
+ next = next.adding(days: 1)
+ }
+ guard let end = nextStart else { return nil }
+ return LocalDayWindow(date: date,
+ start: dayStart,
+ nextStart: end,
+ utcOffsetSeconds: offsetSeconds(at: dayStart))
+ }
+
+ /// The start of the local day that contains `instant`, as an instant and without formatting a date
+ /// on the way.
+ public func startOfDay(containing instant: Date) -> Date {
+ let date = localDate(of: instant)
+ // `instant` lies on `date` by construction, so the date exists and has a start. The fallback
+ // is unreachable and exists only to keep the operation total.
+ guard let start = start(of: date) else { return instant }
+ return start
+ }
+
+ /// The `YYYY-MM-DD` key of the local day that contains `instant`.
+ ///
+ /// The key and the window agree by construction: the key names the date whose window contains the
+ /// instant, because both are derived from the same zone lookup.
+ public func dayKey(for instant: Date) -> String {
+ localDate(of: instant).key
+ }
+
+ /// The local calendar date that contains `instant` in this zone.
+ public func localDate(of instant: Date) -> LocalCalendarDate {
+ localDate(ofEpochSeconds: Int64(instant.timeIntervalSince1970.rounded(.down)))
+ }
+
+ /// Up to `count` consecutive local calendar dates ending at `date`, oldest first, each with its own
+ /// resolved start.
+ ///
+ /// A date the zone skipped is omitted rather than replaced, so the run can be shorter than
+ /// requested; if `date` itself does not exist, the newest entry is the latest existing date before
+ /// it.
+ public func backwardRun(endingAt date: LocalCalendarDate, count: Int) -> [LocalDayStart] {
+ guard count > 0 else { return [] }
+ let endDay = date.daysSinceEpoch
+ var run: [LocalDayStart] = []
+ run.reserveCapacity(count)
+ for back in stride(from: count - 1, through: 0, by: -1) {
+ let day = LocalCalendarDate(daysSinceEpoch: endDay - back)
+ if let start = start(of: day) {
+ run.append(LocalDayStart(date: day, start: start))
+ }
+ }
+ return run
+ }
+
+ /// The end of the sleep read window of `date`: the earlier of that date's next start and the
+ /// reference instant, so the current day's window never reaches into the future. `nil` when the
+ /// date does not exist.
+ ///
+ /// For a date that begins after the reference instant the end precedes the start — an empty,
+ /// inverted window — by the D7 contract; a caller wanting a non-empty window must not ask for a
+ /// future date.
+ public func sleepReadWindowEnd(of date: LocalCalendarDate) -> Date? {
+ guard let window = window(of: date) else { return nil }
+ return min(window.nextStart, referenceInstant)
+ }
+
+ /// The UTC offset in effect at `instant`, in seconds east of UTC.
+ public func offsetSeconds(at instant: Date) -> Int {
+ timeZone.secondsFromGMT(for: instant)
+ }
+
+ /// The time-zone database version this platform can observe about itself, or `nil` where it cannot.
+ ///
+ /// Only Darwin exposes one; on Linux the Foundation port has no equivalent, and saying so is the
+ /// point — a faked value would claim a fact nobody checked.
+ public static var observedTimeZoneDataVersion: String? {
+ #if canImport(Darwin)
+ return NSTimeZone.timeZoneDataVersion
+ #else
+ return nil
+ #endif
+ }
+
+ /// The note a platform attaches when a fixture value moves: it names the version recorded in the
+ /// oracle and the version this platform observes at run time.
+ ///
+ /// A version difference is not itself a failure — the fixtures are past and stable, so a routine
+ /// toolchain bump must not redden unrelated work. The note exists to make a *value* difference
+ /// explicable. It is a pure function so that its content can be asserted; where a platform cannot
+ /// observe its own version, the note says so in place of a version rather than omitting it.
+ public static func timeZoneDataVersionNote(recorded: String, observed: String?) -> String {
+ let observedText = observed.map { "observes \($0)" }
+ ?? "cannot observe its own time-zone database version"
+ return "time-zone database version: the oracle records \(recorded); this platform \(observedText)."
+ }
+
+ /// The UTC offset in effect at a whole-second instant.
+ private func offsetSeconds(atEpochSeconds seconds: Int64) -> Int {
+ timeZone.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(seconds)))
+ }
+
+ /// The local calendar date of a whole-second instant.
+ private func localDate(ofEpochSeconds seconds: Int64) -> LocalCalendarDate {
+ let localSeconds = seconds + Int64(offsetSeconds(atEpochSeconds: seconds))
+ var days = localSeconds / 86_400
+ if localSeconds < 0 && localSeconds % 86_400 != 0 { days -= 1 }
+ return LocalCalendarDate(daysSinceEpoch: Int(days))
+ }
+
+ /// The instant at which the zone's offset changes near `localMidnight`, found by bisecting the
+ /// probe window between the offset before and the offset after.
+ private func transitionEpochSeconds(around localMidnight: Int64, offsetBefore: Int) -> Int64 {
+ var low = localMidnight - Self.probeWindow
+ var high = localMidnight + Self.probeWindow
+ while high - low > 1 {
+ let mid = low + (high - low) / 2
+ if offsetSeconds(atEpochSeconds: mid) == offsetBefore {
+ low = mid
+ } else {
+ high = mid
+ }
+ }
+ return high
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift
new file mode 100644
index 0000000000..3527481319
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift
@@ -0,0 +1,120 @@
+import Foundation
+import WhoopProtocol
+
+public enum PhysiologicalSteps {
+ public enum SleepKind: Sendable { case mainSleep, nap, unclassified }
+ public struct SleepBlock: Sendable {
+ public let onset: Int
+ public let end: Int
+ public let id: String
+ public let editedOnset: Int?
+ public let kind: SleepKind
+ public var effectiveOnset: Int { editedOnset ?? onset }
+ public init(onset: Int, end: Int, id: String? = nil, editedOnset: Int? = nil,
+ kind: SleepKind = .unclassified) {
+ self.onset = onset; self.end = end; self.id = id ?? String(onset)
+ self.editedOnset = editedOnset; self.kind = kind
+ }
+ }
+ public struct CycleBoundary: Equatable, Sendable {
+ public let sleepId: String
+ public let onset: Int
+ public init(sleepId: String, onset: Int) { self.sleepId = sleepId; self.onset = onset }
+ }
+ public struct CycleWindow: Equatable, Sendable {
+ public let sleepId: String; public let onset: Int; public let endExclusive: Int
+ public init(sleepId: String, onset: Int, endExclusive: Int) {
+ self.sleepId = sleepId; self.onset = onset; self.endExclusive = endExclusive
+ }
+ }
+ public struct OwnerSegment: Equatable, Sendable {
+ public let owner: String; public let onset: Int; public let endExclusive: Int
+ public init(owner: String, onset: Int, endExclusive: Int) {
+ self.owner = owner; self.onset = onset; self.endExclusive = endExclusive
+ }
+ }
+ public struct OwnerCoverage: Equatable, Sendable {
+ public let owner: String; public let onset: Int; public let endExclusive: Int; public let priority: Int
+ public init(owner: String, onset: Int, endExclusive: Int, priority: Int) {
+ self.owner = owner; self.onset = onset; self.endExclusive = endExclusive; self.priority = priority
+ }
+ }
+ private static let minMainSleepSeconds = 3 * 3_600
+
+ /// Kotlin twin: `PhysiologicalSteps.classifyForCycle`.
+ public static func classifyForCycle(_ blocks: [SleepBlock], offsetSec: Int,
+ habitualMidsleepSec: Int?) -> [SleepBlock] {
+ guard !blocks.isEmpty else { return [] }
+ let explicit = blocks.indices.filter { blocks[$0].kind == .mainSleep }
+ let selectable = blocks.indices.filter { blocks[$0].kind != .nap }
+ let selected: Set
+ if !explicit.isEmpty {
+ selected = Set(explicit)
+ } else {
+ let nightBlocks = selectable.map { SleepStageTotals.NightBlock(start: blocks[$0].effectiveOnset,
+ end: blocks[$0].end) }
+ let eligible = SleepStageTotals.bridgedNightGroups(nightBlocks, offsetSec: offsetSec)
+ .filter { group in
+ let total = group.indices.reduce(0) { $0 + max(0, nightBlocks[$1].durationS) }
+ let onset = group.indices.map { nightBlocks[$0].start }.min()
+ return total >= minMainSleepSeconds && onset.map {
+ SleepStageTotals.isOvernightOnset($0, offsetSec: offsetSec)
+ } == true
+ }
+ .flatMap { $0.indices }
+ let candidates = eligible.map { index in
+ SleepStageTotals.NightBlock(start: blocks[selectable[index]].effectiveOnset,
+ end: blocks[selectable[index]].end)
+ }
+ let picked = SleepStageTotals.mainNightGroupIndices(candidates, offsetSec: offsetSec,
+ habitualMidsleepSec: habitualMidsleepSec) ?? []
+ selected = Set(picked.map { selectable[eligible[$0]] })
+ }
+ return blocks.indices.map { index in
+ let block = blocks[index]
+ return SleepBlock(onset: block.onset, end: block.end, id: block.id,
+ editedOnset: block.editedOnset,
+ kind: selected.contains(index) ? .mainSleep : .nap)
+ }
+ }
+
+ /// Kotlin twin: `PhysiologicalSteps.cycleWindows`.
+ public static func cycleWindows(_ boundaries: [CycleBoundary], now: Int) -> [CycleWindow] {
+ var ids = Set()
+ let ordered = boundaries.filter { $0.onset <= now && ids.insert($0.sleepId).inserted }
+ .sorted { $0.onset < $1.onset }
+ return ordered.indices.compactMap { index in
+ let end = index + 1 < ordered.count ? ordered[index + 1].onset : now
+ return end > ordered[index].onset
+ ? CycleWindow(sleepId: ordered[index].sleepId, onset: ordered[index].onset, endExclusive: end)
+ : nil
+ }
+ }
+
+ /// Kotlin twin: `PhysiologicalSteps.ownerSegmentsFromCoverage`.
+ public static func ownerSegmentsFromCoverage(_ window: CycleWindow, coverage: [OwnerCoverage],
+ fallbackOwner: String) -> [OwnerSegment] {
+ guard window.endExclusive > window.onset else { return [] }
+ let clipped = coverage.compactMap { item -> OwnerCoverage? in
+ let start = max(window.onset, item.onset), end = min(window.endExclusive, item.endExclusive)
+ return end > start ? OwnerCoverage(owner: item.owner, onset: start,
+ endExclusive: end, priority: item.priority) : nil
+ }
+ let seams = Set([window.onset, window.endExclusive] + clipped.flatMap { [$0.onset, $0.endExclusive] }).sorted()
+ var result: [OwnerSegment] = [], lastOwner = fallbackOwner
+ for index in 0..<(seams.count - 1) {
+ let start = seams[index], end = seams[index + 1]
+ let owner = clipped.filter { start >= $0.onset && start < $0.endExclusive }
+ .sorted { $0.priority == $1.priority ? $0.owner < $1.owner : $0.priority < $1.priority }
+ .first?.owner ?? lastOwner
+ if let last = result.last, last.owner == owner, last.endExclusive == start {
+ result[result.count - 1] = OwnerSegment(owner: owner, onset: last.onset, endExclusive: end)
+ } else {
+ result.append(OwnerSegment(owner: owner, onset: start, endExclusive: end))
+ }
+ lastOwner = owner
+ }
+ return result
+ }
+
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PreSleepHeartRateFeedback.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PreSleepHeartRateFeedback.swift
new file mode 100644
index 0000000000..a486d021d6
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PreSleepHeartRateFeedback.swift
@@ -0,0 +1,285 @@
+import Foundation
+import WhoopProtocol
+import WhoopStore
+
+/// An opt-in, descriptive pre-sleep heart-rate reading for the following morning.
+///
+/// The caller supplies a timestamp-coalesced HR timeline: Repository's existing HR reads already
+/// coalesce measured and PPG-derived samples, with measured data taking precedence. This type selects
+/// the longest supplied sleep session, reads the declared half-open window immediately before it, and
+/// compares that observation with a personal rolling baseline. It neither diagnoses nor recommends an
+/// action. Journal answers are returned only as facts from the same day; one night cannot establish an
+/// association, so they never alter the reading or become an insight.
+public enum PreSleepHeartRateFeedback {
+ /// Baseline configuration deliberately shares the existing HR plausibility range while keeping a
+ /// small floor spread for transparent personal comparison. `rollingMeanSD` is the existing auditable
+ /// baseline primitive; this is not a new scoring model.
+ public static let baselineCfg = MetricCfg(minVal: SleepHeartRateContrast.validMinBpm,
+ maxVal: SleepHeartRateContrast.validMaxBpm,
+ floorSpread: 2,
+ halfLifeB: 14,
+ halfLifeS: 21)
+ public static let defaultPreSleepWindowSeconds = 30 * 60
+ public static let defaultMinimumValidSamples = 10
+ public static let minimumBaselineNights = Baselines.minNightsSeed
+
+ /// A prior eligible pre-sleep observation. Callers persist/assemble history; this pure slice does
+ /// not write, schedule a prompt, or penalize a missing night.
+ public struct HistoricalReading: Equatable, Sendable {
+ public let day: String
+ public let meanBpm: Double
+
+ public init(day: String, meanBpm: Double) {
+ self.day = day
+ self.meanBpm = meanBpm
+ }
+ }
+
+ /// Why a morning reading is or is not available. Every non-eligible state is recoverable on a
+ /// later, sufficiently covered night; none creates a streak, goal, or obligation.
+ public enum Eligibility: Equatable, Sendable {
+ case disabled
+ case invalidDay
+ case invalidWindow
+ case missingPrimarySleep
+ case insufficientPreSleepSamples(valid: Int, required: Int)
+ case insufficientBaseline(validNights: Int, required: Int)
+ case staleBaseline(daysSinceUpdate: Int)
+ case eligible
+ }
+
+ /// The observed, un-imputed pre-sleep data. Completeness is stated as a sample count because raw
+ /// HR cadence can vary; this slice does not pretend a count is a percentage of time covered.
+ public struct Observation: Equatable, Sendable {
+ public let primarySleepStartTs: Int
+ public let primarySleepEndTs: Int
+ public let windowStartTs: Int
+ public let windowEndTs: Int
+ public let meanBpm: Double
+ public let validSamples: Int
+ public let totalTimestampSamples: Int
+
+ public init(primarySleepStartTs: Int, primarySleepEndTs: Int,
+ windowStartTs: Int, windowEndTs: Int, meanBpm: Double,
+ validSamples: Int, totalTimestampSamples: Int) {
+ self.primarySleepStartTs = primarySleepStartTs
+ self.primarySleepEndTs = primarySleepEndTs
+ self.windowStartTs = windowStartTs
+ self.windowEndTs = windowEndTs
+ self.meanBpm = meanBpm
+ self.validSamples = validSamples
+ self.totalTimestampSamples = totalTimestampSamples
+ }
+ }
+
+ /// A personal comparison only, never a population norm or a health classification.
+ public struct Comparison: Equatable, Sendable {
+ public let baselineBpm: Double
+ public let deltaBpm: Double
+ public let baselineNights: Int
+ public let baselineStatus: BaselineStatus
+
+ public init(baselineBpm: Double, deltaBpm: Double, baselineNights: Int,
+ baselineStatus: BaselineStatus) {
+ self.baselineBpm = baselineBpm
+ self.deltaBpm = deltaBpm
+ self.baselineNights = baselineNights
+ self.baselineStatus = baselineStatus
+ }
+ }
+
+ /// Explicit limits that a presentation layer must keep visible rather than turning into certainty.
+ public enum Uncertainty: Equatable, Sendable {
+ /// The first eligible personal baseline is usable but not yet trusted by `Baselines`.
+ case provisionalBaseline
+ /// There are not yet enough valid historical nights to make any personal comparison.
+ case noPersonalComparison
+ /// A mature personal baseline has not received a plausible value recently enough to compare.
+ case staleBaseline(daysSinceUpdate: Int)
+ }
+
+ /// This slice intentionally makes no causal inference from a single observation or its journal facts.
+ public enum Inference: Equatable, Sendable { case notEstablished }
+ /// This slice intentionally cannot support a behavior, treatment, or other recommendation.
+ public enum Recommendation: Equatable, Sendable { case unsupported }
+
+ /// A projection of a same-day `JournalEntry`. It preserves what was logged without exposing notes or
+ /// claiming that the entry explains this reading.
+ public struct JournalFact: Equatable, Sendable {
+ public let day: String
+ public let question: String
+ public let answeredYes: Bool
+ public let numericValue: Double?
+
+ public init(day: String, question: String, answeredYes: Bool, numericValue: Double?) {
+ self.day = day
+ self.question = question
+ self.answeredYes = answeredYes
+ self.numericValue = numericValue
+ }
+ }
+
+ public struct Feedback: Equatable, Sendable {
+ public let eligibility: Eligibility
+ public let observation: Observation?
+ public let comparison: Comparison?
+ public let uncertainty: [Uncertainty]
+ public let inference: Inference
+ public let recommendation: Recommendation
+ /// Same-day journal facts only. They are deliberately not treated as a causal insight.
+ public let journalContext: [JournalFact]
+
+ public init(eligibility: Eligibility, observation: Observation?, comparison: Comparison?,
+ uncertainty: [Uncertainty], inference: Inference,
+ recommendation: Recommendation, journalContext: [JournalFact]) {
+ self.eligibility = eligibility
+ self.observation = observation
+ self.comparison = comparison
+ self.uncertainty = uncertainty
+ self.inference = inference
+ self.recommendation = recommendation
+ self.journalContext = journalContext
+ }
+ }
+
+ /// Produce the next-morning reading from existing timestamped HR and sleep primitives.
+ ///
+ /// `hr` is expected to be the repository's measured/PPG-coalesced timeline. To remain safe for other
+ /// callers, duplicate timestamps are also ignored after the first element, preserving the caller's
+ /// precedence order. Both window bounds are transparent and half-open: `[sleep.start - window, sleep.start)`.
+ public static func evaluate(enabled: Bool, sessions: [SleepSession], hr: [HRSample],
+ history: [HistoricalReading], journalEntries: [JournalEntry], day: String,
+ minimumValidSamples: Int = defaultMinimumValidSamples,
+ preSleepWindowSeconds: Int = defaultPreSleepWindowSeconds) -> Feedback {
+ let unsupported = Recommendation.unsupported
+ let noInference = Inference.notEstablished
+ guard enabled else {
+ return Feedback(eligibility: .disabled, observation: nil, comparison: nil, uncertainty: [],
+ inference: noInference, recommendation: unsupported, journalContext: [])
+ }
+ guard let evaluationDate = canonicalDay(day) else {
+ return Feedback(eligibility: .invalidDay, observation: nil, comparison: nil, uncertainty: [],
+ inference: noInference, recommendation: unsupported, journalContext: [])
+ }
+ guard minimumValidSamples > 0, preSleepWindowSeconds > 0 else {
+ return Feedback(eligibility: .invalidWindow, observation: nil, comparison: nil, uncertainty: [],
+ inference: noInference, recommendation: unsupported, journalContext: [])
+ }
+ let sessionsWithDuration = sessions.compactMap { session -> (session: SleepSession, duration: Int)? in
+ let (duration, overflow) = session.end.subtractingReportingOverflow(session.start)
+ return !overflow && duration > 0 ? (session, duration) : nil
+ }
+ guard let primary = sessionsWithDuration.max(by: { $0.duration < $1.duration })?.session else {
+ return Feedback(eligibility: .missingPrimarySleep, observation: nil, comparison: nil, uncertainty: [],
+ inference: noInference, recommendation: unsupported, journalContext: [])
+ }
+
+ let (start, windowOverflow) = primary.start.subtractingReportingOverflow(preSleepWindowSeconds)
+ guard !windowOverflow else {
+ return Feedback(eligibility: .invalidWindow, observation: nil, comparison: nil, uncertainty: [],
+ inference: noInference, recommendation: unsupported, journalContext: [])
+ }
+ let inWindow = deduplicated(hr).filter { $0.ts >= start && $0.ts < primary.start }
+ let valid = inWindow.filter { baselineCfg.minVal <= Double($0.bpm) && Double($0.bpm) <= baselineCfg.maxVal }
+ guard valid.count >= minimumValidSamples else {
+ return Feedback(eligibility: .insufficientPreSleepSamples(valid: valid.count, required: minimumValidSamples),
+ observation: nil, comparison: nil, uncertainty: [], inference: noInference,
+ recommendation: unsupported, journalContext: [])
+ }
+ let mean = Double(valid.reduce(0) { $0 + $1.bpm }) / Double(valid.count)
+ let observation = Observation(primarySleepStartTs: primary.start, primarySleepEndTs: primary.end,
+ windowStartTs: start, windowEndTs: primary.start, meanBpm: mean,
+ validSamples: valid.count, totalTimestampSamples: inWindow.count)
+ // Baselines use prior nights only and
+ // `rollingMeanSD` requires oldest-to-newest input. Retain the first caller-supplied reading
+ // for a repeated canonical day; one day contributes at most one night and no synthetic average
+ // is invented.
+ var seenDays = Set()
+ let priorHistory = history
+ .compactMap { reading -> (reading: HistoricalReading, date: Date)? in
+ guard let date = canonicalDay(reading.day), date < evaluationDate,
+ seenDays.insert(reading.day).inserted else { return nil }
+ return (reading, date)
+ }
+ .sorted { $0.date < $1.date }
+ let rollingBaseline = Baselines.rollingMeanSD(priorHistory.map(\.reading.meanBpm), cfg: baselineCfg)
+ let latestContributingDate = priorHistory.last {
+ baselineCfg.minVal <= $0.reading.meanBpm && $0.reading.meanBpm <= baselineCfg.maxVal
+ }?.date
+ let nightsSinceUpdate = latestContributingDate.flatMap {
+ gregorianUTC.dateComponents([.day], from: $0, to: evaluationDate).day
+ }.map { max(0, $0) } ?? 0
+ let baseline = BaselineState(
+ baseline: rollingBaseline.baseline,
+ spread: rollingBaseline.spread,
+ nValid: rollingBaseline.nValid,
+ nightsSinceUpdate: nightsSinceUpdate,
+ status: Baselines.computeStatus(nValid: rollingBaseline.nValid,
+ nightsSinceUpdate: nightsSinceUpdate)
+ )
+ let context = journalEntries.filter { $0.day == day }.map {
+ JournalFact(day: $0.day, question: $0.question, answeredYes: $0.answeredYes,
+ numericValue: $0.numericValue)
+ }
+ guard baseline.nValid >= minimumBaselineNights else {
+ return Feedback(eligibility: .insufficientBaseline(validNights: baseline.nValid,
+ required: minimumBaselineNights),
+ observation: observation, comparison: nil, uncertainty: [.noPersonalComparison],
+ inference: noInference, recommendation: unsupported, journalContext: context)
+ }
+ guard baseline.usable else {
+ return Feedback(eligibility: .staleBaseline(daysSinceUpdate: baseline.nightsSinceUpdate),
+ observation: observation, comparison: nil,
+ uncertainty: [.staleBaseline(daysSinceUpdate: baseline.nightsSinceUpdate)],
+ inference: noInference, recommendation: unsupported, journalContext: context)
+ }
+
+ let comparison = Comparison(baselineBpm: baseline.baseline, deltaBpm: mean - baseline.baseline,
+ baselineNights: baseline.nValid, baselineStatus: baseline.status)
+ let uncertainty: [Uncertainty] = baseline.status == .trusted ? [] : [.provisionalBaseline]
+ return Feedback(eligibility: .eligible, observation: observation, comparison: comparison,
+ uncertainty: uncertainty, inference: noInference, recommendation: unsupported,
+ journalContext: context)
+ }
+
+ /// Stored, not computed (#1784). `canonicalDay` reads this once per history row, so a computed
+ /// property built a fresh `Calendar` for every prior night. `Calendar` is a `Sendable` value type,
+ /// so one shared instance is safe and the construction happens once.
+ private static let gregorianUTC: Calendar = {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)!
+ return calendar
+ }()
+
+ private static func canonicalDay(_ day: String) -> Date? {
+ let bytes = Array(day.utf8)
+ guard bytes.count == 10, bytes[4] == 45, bytes[7] == 45 else { return nil }
+ let digitPositions = [0, 1, 2, 3, 5, 6, 8, 9]
+ guard digitPositions.allSatisfy({ (48...57).contains(bytes[$0]) }) else { return nil }
+
+ let year = Int(bytes[0] - 48) * 1_000 + Int(bytes[1] - 48) * 100
+ + Int(bytes[2] - 48) * 10 + Int(bytes[3] - 48)
+ let month = Int(bytes[5] - 48) * 10 + Int(bytes[6] - 48)
+ let dayOfMonth = Int(bytes[8] - 48) * 10 + Int(bytes[9] - 48)
+ let calendar = gregorianUTC
+ guard let date = calendar.date(from: DateComponents(year: year, month: month, day: dayOfMonth)) else {
+ return nil
+ }
+ let roundTrip = calendar.dateComponents([.era, .year, .month, .day], from: date)
+ guard roundTrip.era == 1 && roundTrip.year == year && roundTrip.month == month
+ && roundTrip.day == dayOfMonth else { return nil }
+ return date
+ }
+
+ /// One sample per timestamp, FIRST occurrence wins.
+ ///
+ /// First-wins is the caller's precedence order, not an arbitrary pick: `evaluate`'s contract is an
+ /// already-coalesced timeline where measured data outranks PPG-derived, so the first element for a
+ /// timestamp is the one the repository chose. Stated here as well as on `evaluate` because the rule
+ /// is invisible at this call site, and a later reader changing it to last-wins would silently invert
+ /// that precedence (#1784).
+ private static func deduplicated(_ hr: [HRSample]) -> [HRSample] {
+ var seen = Set()
+ return hr.filter { seen.insert($0.ts).inserted }
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift
index 2c411b1f63..78f0070e60 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift
@@ -217,128 +217,6 @@ public enum RecoveryScorer {
dampFraction: dampFraction)
}
- /// Rolling-mean HR window (seconds) for the resting-HR estimate.
- public static let restingHRWindowS: Int = 5 * 60
-
- /// Minimum HR samples a 5-min bin must hold before its mean is eligible to WIN the resting
- /// floor (#686). A thinly-populated bin — at the limit a single lone beat — lets one artifact
- /// (a dropout, a decode glitch) become the bin "mean" and win the night's minimum, dragging
- /// resting HR implausibly low. Requiring a handful of samples means the floor is a genuine
- /// sustained dip, not a one-sample fluke. Worn nights stream ~1 Hz HR so a real 5-min bin holds
- /// hundreds of samples and clears this trivially; only sparse/edge bins (a partial trailing bin,
- /// a gap-straddling bin) fall below it. Does NOT change the floor DEFINITION — still the min of
- /// 5-min bin means — it only stops an under-sampled artifact bin from being that min.
- public static let restingHRMinBinSamples: Int = 5
-
- /// Physiological resting-HR floor (bpm) below which a bin mean is rejected as a dropout artifact
- /// (#686), never the resting floor. An adult's true sleeping resting HR essentially never sits
- /// below this; a 5-min mean that does is a run of dropout/decode-zero beats, not a real cardiac
- /// dip. 25 bpm clears even deeply-bradycardic trained athletes (resting HRs in the low 30s) with
- /// margin while rejecting the implausible artifact range. A bin below this is excluded from floor
- /// candidacy; if it were allowed to win, resting HR would read a fabricated sub-physiological value.
- public static let restingHRMinPlausibleBpm: Double = 25.0
-
- // MARK: - Resting HR
-
- /// Lowest sustained HR during the in-bed window (bpm, rounded), or nil.
- ///
- /// "Sustained" = the minimum of 5-minute non-overlapping bin means of the HR
- /// samples whose ts ∈ [start, end]. Rejects single-beat dips while capturing
- /// the night's true floor. Returns nil when there are no HR samples in window.
- ///
- /// Artifact hardening (#686): a bin may only WIN the floor when it is BOTH well-populated
- /// (≥ `restingHRMinBinSamples`, so one lone artifact beat can't be a bin "mean") AND
- /// physiologically plausible (mean ≥ `restingHRMinPlausibleBpm`, rejecting dropout-driven
- /// sub-physiological dips). The floor DEFINITION is unchanged — still the minimum of the
- /// 5-min bin means — only artifact bins are barred from being that minimum. If no bin
- /// qualifies (a wholly sparse/degenerate window), fall back to the lowest of ALL bin means,
- /// else the all-sample mean, preserving the never-nil-on-data behaviour.
- public static func restingHR(_ hr: [HRSample], start: Int, end: Int) -> Int? {
- let seg = hr.filter { $0.ts >= start && $0.ts <= end }
- guard !seg.isEmpty else { return nil }
-
- var means: [Double] = [] // every bin mean (legacy floor, the fallback)
- var qualified: [Double] = [] // bins eligible to WIN the floor (#686)
- var t = start
- while t < end {
- let win = seg.filter { $0.ts >= t && $0.ts < t + restingHRWindowS }
- if !win.isEmpty {
- let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count)
- means.append(mean)
- // A bin wins the floor only if it is well-populated AND physiologically plausible —
- // a thin (single-artifact) or sub-physiological (dropout) bin can't be the minimum.
- if win.count >= restingHRMinBinSamples && mean >= restingHRMinPlausibleBpm {
- qualified.append(mean)
- }
- }
- t += restingHRWindowS
- }
- let floor: Double
- if let m = qualified.min() {
- floor = m
- } else if let m = means.min() {
- // No bin cleared the artifact bar (sparse window): fall back to the legacy floor.
- floor = m
- } else {
- floor = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count)
- }
- return Int(floor.rounded())
- }
-
- // MARK: - Recovery Index (overnight HR-decline slope)
-
- /// Minimum 5-minute bins (`restingHR`'s SAME binning) required before a slope is trusted —
- /// below this, too little of the night has elapsed to fit a trend, and a 1-2-point
- /// regression is noise, not a night-long pattern. 6 bins = 30 minutes of binned coverage,
- /// a deliberately low floor so a short/partial night still gets a number rather than a
- /// routine nil.
- public static let recoveryIndexMinBins: Int = 6
-
- /// Overnight resting-HR DECLINE slope (bpm/hour) across the in-bed window — the "Recovery
- /// Index" component of Oura's Readiness that Charge lacked (it previously only read the
- /// overnight FLOOR via `restingHR` above, never the trend that reaches it).
- ///
- /// Computed as the least-squares slope of the SAME non-overlapping 5-minute HR bin means
- /// `restingHR` uses (`restingHRWindowS`) against each bin's midpoint time (hours from
- /// `start`). NEGATIVE = declining (HR falling through the night — the physiologically
- /// expected, good pattern); POSITIVE = rising (restlessness, illness, alcohol, a late
- /// stimulant). Returns nil when fewer than `recoveryIndexMinBins` bins have data (too
- /// little of the window to fit a trend) or there are no samples at all — it never
- /// fabricates a slope from a sliver of the night.
- public static func recoveryIndexSlope(_ hr: [HRSample], start: Int, end: Int) -> Double? {
- let seg = hr.filter { $0.ts >= start && $0.ts <= end }
- guard !seg.isEmpty else { return nil }
-
- // Same non-overlapping 5-minute binning as restingHR: both read the identical
- // underlying series, one as a floor, one as a trend across it.
- var points: [(tHours: Double, meanBpm: Double)] = []
- var t = start
- while t < end {
- let win = seg.filter { $0.ts >= t && $0.ts < t + restingHRWindowS }
- if !win.isEmpty {
- let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count)
- let midpointS = Double(t - start) + Double(restingHRWindowS) / 2.0
- points.append((tHours: midpointS / 3600.0, meanBpm: mean))
- }
- t += restingHRWindowS
- }
- guard points.count >= recoveryIndexMinBins else { return nil }
-
- // Least-squares slope: Σ((t-t̄)(y-ȳ)) / Σ((t-t̄)²), bpm per hour.
- let n = Double(points.count)
- let tBar = points.reduce(0.0) { $0 + $1.tHours } / n
- let yBar = points.reduce(0.0) { $0 + $1.meanBpm } / n
- var num = 0.0, den = 0.0
- for p in points {
- let dt = p.tHours - tBar
- num += dt * (p.meanBpm - yBar)
- den += dt * dt
- }
- // Degenerate (all bins at the same instant): no time spread to fit against.
- guard den > 1e-9 else { return 0.0 }
- return num / den
- }
-
// MARK: - Recovery band
/// WHOOP-style color band for a recovery score [0, 100].
@@ -422,8 +300,8 @@ public enum RecoveryScorer {
/// no-skin-temp score is identical to before.
/// - hrvBaselineUsable: whether the HRV baseline has enough nights
/// (BaselineState.usable). When false, returns nil (cold-start).
- /// - recoveryIndexSlope: overnight resting-HR DECLINE slope (bpm/hour, from
- /// `recoveryIndexSlope(_:start:end:)`). Negative (declining) supports recovery;
+ /// - recoveryIndexSlope: overnight resting-HR DECLINE slope (bpm/hour), supplied by the
+ /// caller. Negative (declining) supports recovery;
/// positive (rising) limits it, weight wRecoveryIndex. nil (the default) drops the
/// term and the weights renormalize, so the no-slope score is IDENTICAL to before
/// this parameter existed.
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift
index 5456fe079c..0d3fef9edc 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RrEmissionStats.swift
@@ -127,6 +127,34 @@ public enum RrEmissionStats {
spanSec: span, ratio: ratio, perSecond: hist, gapHist: gapHist, fill: fill)
}
+ /// 15 minutes: rare enough not to bury the log, frequent enough to sample a night several times.
+ public static let liveCensusMinGapSec: Int = 900
+
+ /// Should a LIVE census line be emitted now? (#1118 instrumentation.)
+ ///
+ /// The historical census is emitted once per offload SESSION, which is naturally rare. The two live
+ /// paths flush roughly every 30 readings, so emitting per flush would put a line on the strap log
+ /// every ~30 seconds all night — a volume that buries the very evidence it exists to provide. One
+ /// line per path per `minGapSec` is plenty: a single flush already spans enough seconds to make
+ /// `ratioRep` meaningful (the historical line settles it on 39 s), and what matters is comparing the
+ /// paths, not sampling either densely.
+ ///
+ /// `lastEmitSec` is 0 before the first line, so the first flush of a connection always reports.
+ /// A clock that moves BACKWARDS emits rather than suppresses: the alternative wedges the census shut
+ /// until wall time catches up, and a silent instrument is worse than a duplicated line.
+ ///
+ /// Byte-parity twin of Kotlin `RrEmissionStats.shouldEmitLiveCensus`.
+ public static func shouldEmitLiveCensus(lastEmitSec: Int, nowSec: Int,
+ minGapSec: Int = liveCensusMinGapSec) -> Bool {
+ // The "never emitted" sentinel is checked EXPLICITLY. Falling through to the gap arithmetic
+ // gets the right answer in production only because a real unix `nowSec` is ~1.8e9 and so
+ // trivially clears any gap — an accident of magnitude, not a rule. It also made the helper
+ // untestable with small timestamps, which is how this was caught.
+ if lastEmitSec <= 0 { return true }
+ if nowSec < lastEmitSec { return true }
+ return nowSec - lastEmitSec >= minGapSec
+ }
+
/// One compact log line. `offered`/`inserted` come from the caller: `inserted` is what the store
/// actually wrote after its `ON CONFLICT` key, so `offered - inserted` is how much the primary key
/// already absorbs — the third number needed to tell emission from ingest.
@@ -140,11 +168,16 @@ public enum RrEmissionStats {
/// gapless, and `ratioRep` is the one to trust when they disagree. (`ratioRep`'s denominator can
/// double-count at most one second per chunk boundary when a session's counts are summed, which is
/// negligible against thousands of seconds — and errs the same safe way, low.)
- public static func logLine(path: String, offered: Int, inserted: Int, _ r: Result) -> String {
+ public static func logLine(path: String, offered: Int, inserted: Int?, _ r: Result) -> String {
+ // NIL renders "n/a", never a number. Only the historical path can see what the store's conflict
+ // key actually kept; the live paths census BEFORE the insert and have no such count. Echoing
+ // `offered` there would print `offered=N inserted=N`, which reads as "the primary key absorbed
+ // nothing" — a measurement neither path made. Same reason GpsSession passes rawFixes = nil.
+ let ins = inserted.map(String.init) ?? "n/a"
let ratio = String(format: "%.2f", r.ratio)
let rep = r.secondsWithRr > 0 ? Double(r.sumRrMs) / 1000.0 / Double(r.secondsWithRr) : 0
let h = r.perSecond
- return "rr emit path=\(path) offered=\(offered) inserted=\(inserted) secs=\(r.secondsWithRr) "
+ return "rr emit path=\(path) offered=\(offered) inserted=\(ins) secs=\(r.secondsWithRr) "
+ "sumRr=\(r.sumRrMs / 1000)s span=\(r.spanSec)s ratio=\(ratio) "
+ "ratioRep=\(String(format: "%.2f", rep)) "
+ "perSec[1/2/3/4+]=\(h[0])/\(h[1])/\(h[2])/\(h[3]) "
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift
index 6c7b2f0217..b5d5978e42 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ScoreConfidence.swift
@@ -1,4 +1,5 @@
import Foundation
+import WhoopStore
// ScoreConfidence.swift — per-score certainty tier for Charge / Effort / Rest.
//
@@ -78,25 +79,39 @@ public enum ScoreConfidence: String, Equatable, Sendable, Codable {
/// suspicious case — high efficiency (lots of measured sleep) but implausibly little restorative.
public static let highEfficiencyThreshold: Double = 0.85
- /// Rest confidence WITH the H9 stage-quality check AND the sparse-motion guard. Starts from
- /// `rest(hasSession:hasStagedSleep:)`, then DOWNGRADES a `.solid` tier to `.building` (low-confidence)
- /// when EITHER:
+ /// Rest confidence WITH the H9 stage-quality check, the sparse-motion guard AND the hypnogram-coverage
+ /// guard. Starts from `rest(hasSession:hasStagedSleep:)`, then DOWNGRADES a `.solid` tier to `.building`
+ /// (low-confidence) when ANY of:
/// - the night was staged on SPARSE gravity (`gravitySparse`) — a WHOOP 4.0 synced/offload night banks
/// motion coarsely, too sparse to reliably stage sleep (#345), so a confident 85–100 Rest is unearned
/// however the engine filled the stages. This catches the case H9 MISSES: a sparse night whose staging
/// manufactures HIGH efficiency AND HIGH restorative reads SOLID under H9 alone (the #319 signature),
/// yet the underlying data can't support it; OR
/// - the night is high-efficiency yet its restorative (deep+REM) share is below
- /// `restorativeLowConfidenceShare` — a likely staging miss (#H9).
+ /// `restorativeLowConfidenceShare` — a likely staging miss (#H9); OR
+ /// - `stageCoverage` says the stage timeline accounts for less than `HypnogramCoverage.minCoverage` of
+ /// the span it claims. This is the case the other two structurally cannot see: a device-PROVIDED
+ /// hypnogram assembled from records that arrived incomplete has real stages over the part that DID
+ /// arrive, so its restorative share is ordinary and H9 stays quiet, while `gravitySparse` describes
+ /// the on-device motion stager and is false for a provided hypnogram (and inert for Oura outright,
+ /// which banks no gravity at all). Measured: a ring night covering 23% of its 601-minute span was
+ /// stored as 70 minutes of sleep and reported SOLID.
/// `asleepSeconds`/`restorativeSeconds` are the night's totals; efficiency is asleep/in-bed in [0,1].
+ /// `stageCoverage` is nil when coverage is unknown or not applicable — the guard fails OPEN there, so
+ /// an unmeasurable payload keeps its previous tier rather than being downgraded on no evidence.
/// `.calibrating`/`.building` from the base call are returned unchanged. Confidence-only — never changes
- /// the Rest score or invents stages. Engine output only; the UI surfaces the tier later. (#H9, #345)
+ /// the Rest score, invents stages, or claims the uncovered time was awake. Engine output only; the UI
+ /// surfaces the tier later. (#H9, #345)
public static func rest(hasSession: Bool, hasStagedSleep: Bool,
asleepSeconds: Double, restorativeSeconds: Double,
- efficiency: Double, gravitySparse: Bool = false) -> ScoreConfidence {
+ efficiency: Double, gravitySparse: Bool = false,
+ stageCoverage: Double? = nil) -> ScoreConfidence {
let base = rest(hasSession: hasSession, hasStagedSleep: hasStagedSleep)
if base != .solid { return base }
if gravitySparse { return .building } // #345: sparse-motion staging can't earn a SOLID Rest
+ if let c = stageCoverage, c < HypnogramCoverage.minCoverage {
+ return .building // the timeline covers only part of the night it claims
+ }
if asleepSeconds <= 0 { return base }
let restorativeShare = restorativeSeconds / asleepSeconds
if efficiency >= highEfficiencyThreshold && restorativeShare < restorativeLowConfidenceShare {
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift
new file mode 100644
index 0000000000..61f29be71c
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift
@@ -0,0 +1,159 @@
+import Foundation
+import WhoopProtocol
+
+/// #1853: backfill the nightly ABSOLUTE skin temperature (`skinTempC`) for nights the 21-night
+/// `analyzeRecent` rescore window never reaches.
+///
+/// `skinTempC` is written only by the scoring pass (`IntelligenceEngine`, #1663, 2026-08-27), which
+/// covers a rolling 21-night window. Nights already outside that window when the column shipped were
+/// never revisited, and nothing walks them since — so a night is missing its absolute if it fell
+/// outside every window that ran after 27 Aug, regardless of whether its raw `skinTempSample` rows
+/// are still on disk (they are: `skinTempSample` has no age-based retention).
+///
+/// This file holds the PURE, database-free half of the backfill: the session-attribution rule and the
+/// per-night plan. The walker that reads from / writes to `WhoopStore` lives in `Strand/Data`.
+///
+/// Three correctness rules (all re-review findings from the Android twin, #1852 — reproduced verbatim
+/// or the Swift backfill will be wrong in the same ways):
+///
+/// 1. **Attribute sessions by END.** The night window is 54 h wide (30 h before local midnight through
+/// the next), so it contains the PREVIOUS night as well. The engine's rule is
+/// `matched = allSessions.filter { tsInDay(it.end) }`; handing the funnel the whole window averages
+/// two nights into one temperature and stores it as the wrong day's.
+///
+/// 2. **Page the candidates.** A plain "oldest N" returns the same nights every pass, and the oldest
+/// are the ones most likely to have lost their raw samples — the sweep latches on page one and never
+/// reaches the newer nights that can fill.
+///
+/// 3. **The WHOOP 4.0 anchor comes from the CURRENT window.** It is learned window-wide, and the
+/// engine's note says that is safe because the offset "cancels in the deviation" — true for a delta,
+/// not for an absolute. A re-learned anchor puts backfilled nights on a different offset from the
+/// stored ones and the chart plots two scales as one line. **No anchor ⇒ decline, never the global
+/// default.**
+public enum SkinTempBackfill {
+
+ /// One night the backfill is considering: the day key, the night's read window (from/to in unix
+ /// seconds), and the device owner whose raw streams should be read. The walker builds these from
+ /// `DailyMetric` rows that have a `skinTempDevC` but no `skinTempC`; the pure plan below decides
+ /// which sessions and samples belong to each.
+ public struct NightCandidate: Equatable, Sendable {
+ public let day: String // yyyy-MM-dd (local-day key, matching DailyMetric.day)
+ public let from: Int // unix seconds: dayStart - StreamReadCap.lookbackSeconds
+ public let to: Int // unix seconds: dayStart + 24h (or now for today)
+ public let owner: String // the device id whose raw streams wrote this night
+ public init(day: String, from: Int, to: Int, owner: String) {
+ self.day = day; self.from = from; self.to = to; self.owner = owner
+ }
+ }
+
+ /// The result of attempting one night's backfill.
+ public struct NightResult: Equatable, Sendable {
+ public let day: String
+ /// The re-derived absolute (°C), or nil when the night declined (no anchor, too few worn
+ /// samples, no sessions, etc.). The walker writes `skinTempC` ONLY when this is non-nil.
+ public let skinTempC: Double?
+ /// Why the night declined, for the diagnostic report. nil when it filled.
+ public let declineReason: String?
+ public init(day: String, skinTempC: Double?, declineReason: String? = nil) {
+ self.day = day; self.skinTempC = skinTempC; self.declineReason = declineReason
+ }
+ }
+
+ /// Rule 1: attribute sessions by END timestamp. The night window is 54 h wide and contains the
+ /// PREVIOUS night as well, so handing the funnel every session in the window averages two nights
+ /// into one temperature. The engine's rule is `matched = allSessions.filter { tsInDay(it.end) }`:
+ /// a session belongs to `day` iff its END falls in `[dayStart, dayStart + 86400)`.
+ ///
+ /// `dayStart` is the LOCAL-midnight unix seconds for the day key (the same `dayStart` the engine's
+ /// per-day loop computes). `sessions` are the raw `SleepSession`s read from the 54 h window.
+ /// Pure + deterministic; the walker tests this directly.
+ public static func sessionsForDay(_ sessions: [SleepSession], dayStart: Int) -> [SleepSession] {
+ let dayEnd = dayStart + 86_400
+ return sessions.filter { $0.end >= dayStart && $0.end < dayEnd }
+ }
+
+ /// Rule 3: the WHOOP 4.0 anchor MUST come from the current scoring window. If no per-device anchor
+ /// could be learned (too few in-band samples), DECLINE the night — do NOT fall back to the global
+ /// 826 anchor. A re-learned anchor puts backfilled nights on a different offset from the stored
+ /// ones and the chart plots two scales as one line.
+ ///
+ /// Returns the anchor to use, or nil to decline. For a 5/MG (`.whoop5`) the anchor is always nil
+ /// and the night proceeds (centidegree path, no anchor).
+ public static func resolveAnchor(family: DeviceFamily, windowAnchorRaw: Double?) -> Double? {
+ switch family {
+ case .whoop4:
+ // No anchor ⇒ decline. The global 826 is NOT a fallback here (rule 3).
+ return windowAnchorRaw
+ case .whoop5:
+ // Centidegree path: no anchor, no decline on anchor grounds.
+ return nil
+ }
+ }
+
+ /// Compute one night's backfill absolute, applying all three rules. Pure + deterministic; the
+ /// walker supplies the raw inputs and writes the result.
+ ///
+ /// - Parameters:
+ /// - candidate: the night to backfill (day, window, owner).
+ /// - sessions: ALL sleep sessions in the 54 h window (the walker reads them once); rule 1
+ /// filters to this day's by end timestamp.
+ /// - hr: HR samples for the night's window.
+ /// - skinTemp: raw skin-temp samples for the night's window.
+ /// - family: the device family that wrote the owner's skin-temp rows (`.whoop4` vs `.whoop5`).
+ /// - windowAnchorRaw: the per-device WHOOP 4.0 anchor learned from the CURRENT scoring window
+ /// (rule 3). nil for a 5/MG or when the current window couldn't learn one.
+ /// - dayStart: the LOCAL-midnight unix seconds for `candidate.day`.
+ /// - wornToleranceSec: the worn-gate timestamp tolerance (0 for WHOOP, >0 for Oura).
+ /// - Returns: the nightly mean (°C) or nil with a decline reason.
+ public static func computeNight(
+ candidate: NightCandidate,
+ sessions: [SleepSession],
+ hr: [HRSample],
+ skinTemp: [SkinTempSample],
+ family: DeviceFamily,
+ windowAnchorRaw: Double?,
+ dayStart: Int,
+ wornToleranceSec: Int = 0
+ ) -> NightResult {
+ // Rule 1: attribute sessions by END. Don't hand the funnel the whole 54 h window.
+ let matched = sessionsForDay(sessions, dayStart: dayStart)
+ if matched.isEmpty {
+ return NightResult(day: candidate.day, skinTempC: nil,
+ declineReason: "no sleep session ends on this day")
+ }
+ // Rule 3: the WHOOP 4.0 anchor comes from the current window. No anchor ⇒ decline.
+ let anchor = resolveAnchor(family: family, windowAnchorRaw: windowAnchorRaw)
+ if family == .whoop4 && anchor == nil {
+ return NightResult(day: candidate.day, skinTempC: nil,
+ declineReason: "no per-device 4.0 anchor from the current window (rule 3: decline, not global fallback)")
+ }
+ // Run the SAME funnel the scoring pass uses — identical inputs, identical result, no second
+ // derivation that could disagree.
+ let diag = AnalyticsEngine.skinTempFunnel(
+ matched, hr: hr, skinTemp: skinTemp, family: family,
+ anchorRaw: anchor, wornToleranceSec: wornToleranceSec)
+ if let mean = diag.mean {
+ return NightResult(day: candidate.day, skinTempC: mean)
+ }
+ // Decline with the funnel's own explanation so the diagnostic report is self-attesting.
+ let reason = diag.kept < diag.minSamples
+ ? "only \(diag.kept)/\(diag.minSamples) worn samples"
+ : "funnel returned no mean"
+ return NightResult(day: candidate.day, skinTempC: nil, declineReason: reason)
+ }
+
+ /// Rule 2: page the candidates so the sweep doesn't latch on the oldest N (which are the most
+ /// likely to have lost their raw samples). Returns the candidates for one page, oldest-first
+ /// within the page. The walker calls this repeatedly with increasing `page` (0-based) until a page
+ /// comes back empty.
+ ///
+ /// `pageSize` caps the per-pass cost (one sleep-window sample read per night). The issue's open
+ /// question on cost is answered by keeping this modest (default 50) and letting the caller decide
+ /// how many pages to run.
+ public static func page(_ candidates: [NightCandidate], page: Int, pageSize: Int = 50) -> [NightCandidate] {
+ let start = page * pageSize
+ guard start < candidates.count else { return [] }
+ let end = min(start + pageSize, candidates.count)
+ return Array(candidates[start.. Kind? {
+ values.last.map(kind(of:))
+ }
+
+ /// A resolved reading: the number to show and the scale it is on.
+ public struct Reading: Sendable, Equatable {
+ public let value: Double
+ public let kind: Kind
+ public init(value: Double, kind: Kind) { self.value = value; self.kind = kind }
+ }
+
+ /// Which of a night's two skin-temp numbers a surface should LEAD with (#1844), the pure form of the
+ /// rule the Health tile has applied since #1665.
+ ///
+ /// The absolute wins whenever the night measured one, because a deviation with no anchor cannot be
+ /// read — "+0.9" is a fever or a warm bedroom and nothing on a card says which. The deviation is the
+ /// fallback, not the default, and keeps its Δ unit so it is never mistaken for a wrist temperature
+ /// (#622).
+ ///
+ /// Two shapes make this more than a preference, both real rows:
+ /// - a night scored before `skinTempC` shipped (2026-08-27) has only a deviation, and keeps exactly
+ /// the display that shipped before until a scoring pass refills it;
+ /// - a CALIBRATING night is the reverse — the deviation is nil until the baseline is usable
+ /// (~4 nights) while the absolute is measured from night one, so those wearers otherwise see an
+ /// empty card with a real temperature sitting behind it.
+ ///
+ /// `prefer` is the user's Settings choice (#1846): `.absolute` — the default, a temperature — or
+ /// `.deviation` for wearers who read the ±baseline move, which is the more sensitive illness signal. It
+ /// picks which number is tried FIRST; the other is still the fallback, so choosing one never blanks a
+ /// card whose night only measured the other, and the unit always names the scale actually shown.
+ ///
+ /// Both values must come from the SAME row; see `DailyMetric.lastSkinTempReadingDay`. Twin of the
+ /// Kotlin `SkinTempDisplay.leadReading`.
+ public static func leadReading(absC: Double?, devC: Double?, prefer: Kind = .absolute) -> Reading? {
+ let first = prefer == .absolute ? absC : devC
+ if let f = first { return Reading(value: f, kind: prefer) }
+ let other = prefer == .absolute ? devC : absC
+ let otherKind: Kind = prefer == .absolute ? .deviation : .absolute
+ if let o = other { return Reading(value: o, kind: otherKind) }
+ return nil
+ }
+
+ /// Formats whichever number `leadReading` chose, with the unit for THAT scale.
+ public static func formatReading(_ reading: Reading, fahrenheit: Bool, decimals: Int = 1) -> String {
+ let n = numberString(reading.value, kind: reading.kind, fahrenheit: fahrenheit, decimals: decimals)
+ return "\(n) \(unitSymbol(kind: reading.kind, fahrenheit: fahrenheit))"
+ }
+
/// Trailing unit chip: `"°C"` / `"°F"` for absolute, `"Δ°C"` / `"Δ°F"` for deviation.
public static func unitSymbol(kind: Kind, fahrenheit: Bool) -> String {
let base = fahrenheit ? "°F" : "°C"
@@ -48,7 +112,13 @@ public enum SkinTempDisplay {
if kind == .absolute {
return String(format: "%.\(decimals)f", display)
}
- return String(format: "%+.\(decimals)f", display)
+ let signed = String(format: "%+.\(decimals)f", display)
+ // #1842: a deviation that ROUNDS to zero must not keep its sign. `%+` prints "-0.0" for anything
+ // in (-0.05, 0), and "-0.0 Δ°C" on a card reads as a fault rather than as "no change from your
+ // baseline" — which is what it means. Drop the sign only when the rounded value is zero; a real
+ // -0.1 keeps it. Twin of the Kotlin numberString.
+ if Double(signed.dropFirst()) == 0 { return String(signed.dropFirst()) }
+ return signed
}
/// Full `"34.2 °C"` / `"+0.1 Δ°C"` string for one-shot call sites (Today cards, explorers).
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepAwareStepCounter.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepAwareStepCounter.swift
new file mode 100644
index 0000000000..fbe95a4025
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepAwareStepCounter.swift
@@ -0,0 +1,137 @@
+import Foundation
+import WhoopProtocol
+
+/// Sleep-context twin of Kotlin `SleepAwareStepCounter`. Counter decisions stay orientation-independent.
+public enum SleepAwareStepCounter {
+ public static let maxSecondsBetweenGaitDeltas = 3
+ public static let minGaitBoutDurationSeconds = 4
+ public static let minGaitBoutActiveSamples = 5
+ public static let minGaitBoutTicks = 6
+
+ public struct Count: Equatable, Sendable {
+ public let totalTicks: Int
+ public let acceptedOutsideSleepTicks: Int
+ public let acceptedAwakeGapTicks: Int
+ public let acceptedSleepBoutTicks: Int
+ public let rejectedIsolatedSleepTicks: Int
+ public let rejectedActivityClassTicks: Int
+ public let rejectedImplausibleTicks: Int
+ public let gravitySamplesAvailable: Int
+ public let auxSamplesAvailable: Int
+
+ public static let empty = Count(totalTicks: 0, acceptedOutsideSleepTicks: 0,
+ acceptedAwakeGapTicks: 0, acceptedSleepBoutTicks: 0, rejectedIsolatedSleepTicks: 0,
+ rejectedActivityClassTicks: 0, rejectedImplausibleTicks: 0,
+ gravitySamplesAvailable: 0, auxSamplesAvailable: 0)
+
+ /// Kotlin twin: `SleepAwareStepCounter.Count.plus`.
+ public func adding(_ other: Count) -> Count {
+ Count(totalTicks: totalTicks + other.totalTicks,
+ acceptedOutsideSleepTicks: acceptedOutsideSleepTicks + other.acceptedOutsideSleepTicks,
+ acceptedAwakeGapTicks: acceptedAwakeGapTicks + other.acceptedAwakeGapTicks,
+ acceptedSleepBoutTicks: acceptedSleepBoutTicks + other.acceptedSleepBoutTicks,
+ rejectedIsolatedSleepTicks: rejectedIsolatedSleepTicks + other.rejectedIsolatedSleepTicks,
+ rejectedActivityClassTicks: rejectedActivityClassTicks + other.rejectedActivityClassTicks,
+ rejectedImplausibleTicks: rejectedImplausibleTicks + other.rejectedImplausibleTicks,
+ gravitySamplesAvailable: gravitySamplesAvailable + other.gravitySamplesAvailable,
+ auxSamplesAvailable: auxSamplesAvailable + other.auxSamplesAvailable)
+ }
+ }
+
+ /// Stateful, overlap-safe counter for database windows read in ascending pages.
+ public final class Accumulator {
+ private let sessions: [SleepSession]
+ private let hasClasses: Bool
+ private var previous: StepSample?
+ private var outside = 0, awake = 0, sleep = 0, rejectedSleep = 0
+ private var rejectedClass = 0, rejectedImplausible = 0
+ private var gravityAvailable = 0, auxAvailable = 0
+ private var pending: [(ts: Int, ticks: Int)] = []
+ private var finished = false
+
+ public init(sleepSessions: [SleepSession], hasActivityClasses: Bool) {
+ sessions = sleepSessions.sorted { $0.start < $1.start }
+ hasClasses = hasActivityClasses
+ }
+
+ /// Kotlin twin: `SleepAwareStepCounter.Accumulator.acceptPage`.
+ @discardableResult public func acceptPage(_ samples: [StepSample]) -> Accumulator {
+ precondition(!finished)
+ for current in samples.sorted(by: { $0.ts < $1.ts }) {
+ guard let prior = previous else { previous = current; continue }
+ guard current.ts > prior.ts else { continue }
+ previous = current
+ let delta = (current.counter - prior.counter) & 0xffff
+ guard StepsCounter.shouldCountDelta(activityClass: current.activityClass,
+ hasActivityClasses: hasClasses) else {
+ rejectedClass += delta; continue
+ }
+ guard StepsCounter.isPlausibleDelta(previousTs: prior.ts, currentTs: current.ts,
+ delta: delta) else {
+ rejectedImplausible += delta; continue
+ }
+ switch SleepAwareStepCounter.context(current.ts, sessions: sessions) {
+ case 0: flush(); outside += delta
+ case 1: flush(); awake += delta
+ default:
+ if let last = pending.last, current.ts - last.ts > maxSecondsBetweenGaitDeltas { flush() }
+ pending.append((current.ts, delta))
+ }
+ }
+ return self
+ }
+
+ /// Kotlin twin: `SleepAwareStepCounter.Accumulator.observeMotionPage`.
+ @discardableResult public func observeMotion(gravityCount: Int, auxCount: Int) -> Accumulator {
+ precondition(!finished)
+ gravityAvailable += gravityCount
+ auxAvailable += auxCount
+ return self
+ }
+
+ /// Kotlin twin: `SleepAwareStepCounter.Accumulator.finish`.
+ public func finish() -> Count {
+ if !finished { flush(); finished = true }
+ return Count(totalTicks: outside + awake + sleep, acceptedOutsideSleepTicks: outside,
+ acceptedAwakeGapTicks: awake, acceptedSleepBoutTicks: sleep,
+ rejectedIsolatedSleepTicks: rejectedSleep,
+ rejectedActivityClassTicks: rejectedClass,
+ rejectedImplausibleTicks: rejectedImplausible,
+ gravitySamplesAvailable: gravityAvailable,
+ auxSamplesAvailable: auxAvailable)
+ }
+
+ /// Kotlin twin: `SleepAwareStepCounter.Accumulator.flushSleepBout`.
+ private func flush() {
+ guard !pending.isEmpty else { return }
+ let ticks = pending.reduce(0) { $0 + $1.ticks }
+ let duration = pending.last!.ts - pending.first!.ts
+ let coherent = pending.count >= minGaitBoutActiveSamples
+ && duration >= minGaitBoutDurationSeconds && ticks >= minGaitBoutTicks
+ if coherent { sleep += ticks } else { rejectedSleep += ticks }
+ pending.removeAll(keepingCapacity: true)
+ }
+ }
+
+ /// Kotlin twin: `SleepAwareStepCounter.stepsInWindow`.
+ public static func stepsInWindow(_ samples: [StepSample], sleepSessions: [SleepSession]) -> Int? {
+ let count = count(samples, sleepSessions: sleepSessions)
+ return count.totalTicks > 0 ? count.totalTicks : nil
+ }
+
+ /// Kotlin twin: `SleepAwareStepCounter.count`.
+ public static func count(_ samples: [StepSample], sleepSessions: [SleepSession]) -> Count {
+ let sorted = samples.sorted { $0.ts < $1.ts }
+ return Accumulator(sleepSessions: sleepSessions,
+ hasActivityClasses: StepsCounter.hasActivityClasses(sorted))
+ .acceptPage(sorted).finish()
+ }
+
+ /// Kotlin twin: `SleepAwareStepCounter.contextAt`.
+ private static func context(_ ts: Int, sessions: [SleepSession]) -> Int {
+ guard let session = sessions.first(where: { ts >= $0.start && ts < $0.end }) else { return 0 }
+ if let stage = session.stages.first(where: { ts >= $0.start && ts < $0.end }),
+ SleepStageVocabulary.isWake(stage.stage) { return 1 }
+ return 2
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift
index 6d711dca63..d4dbae179d 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepDebt.swift
@@ -1,17 +1,16 @@
import Foundation
-// SleepDebt.swift — a rolling sleep-debt ledger over the last N nights.
+// SleepDebt.swift — a recency-weighted sleep-debt estimate over the last N nights.
//
// Pure, deterministic, DB-free. Given a chronological series of per-night total
// sleep (minutes) and a personal sleep need (hours), it accumulates a running
-// balance of (actual − need) per night across a capped trailing window (14 nights
-// by default) and reports the net balance plus the per-night deltas that make it
-// up.
+// debt estimate across a capped trailing window (14 nights by default). The
+// previous estimate is part of the following night's need; 55% of any unmet need
+// carries forward. Tiny, non-actionable results below ten minutes are cleared.
//
// HONEST by construction:
-// - It is a plain debt accumulator — sum of nightly (slept − need) — NOT a
-// physiological model. A surplus night (slept > need) genuinely offsets a
-// deficit one, the same way a checking balance nets credits and debits.
+// - It is a planning estimate, not a physiological claim. Meeting base need plus
+// the current estimate clears the debt; extra sleep never creates a surplus.
// - The window is capped (default 14) so "debt" never compounds indefinitely
// across months of history — only the recent fortnight is in scope.
// - Nights with no usable sleep total are SKIPPED entirely (no zero-fill), so a
@@ -38,10 +37,10 @@ public struct SleepDebtNight: Equatable, Sendable {
}
}
-/// The rolling sleep-debt ledger over the capped trailing window.
+/// The sleep-debt estimate over the capped trailing window.
public struct SleepDebtLedger: Equatable, Sendable {
- /// Net running balance (minutes) across the window: Σ(slept − need). Negative =
- /// net DEBT (under-slept overall), positive = net SURPLUS, 0 = on target.
+ /// Current estimate in the existing signed UI convention: negative = debt,
+ /// zero = on target. This model never reports a positive surplus.
public let balanceMin: Double
/// Per-night contributions, oldest → newest, one per counted night (skipped
/// nights are absent). The `deltaMin` values are the per-night bar/spark.
@@ -67,9 +66,15 @@ public enum SleepDebt {
/// short enough that one rough patch doesn't read as months of compounding debt.
public static let defaultWindowNights: Int = 14
+ /// Share of the complete unmet need carried into the following night.
+ public static let debtCarryFactor: Double = 0.55
+
+ /// Calculated debts smaller than this are too small to be actionable.
+ public static let minimumDebtMin: Double = 10.0
+
/// "On target" deadband (minutes): a |balance| under this reads as balanced rather
/// than as a debt/surplus, so a few stray minutes don't flip the headline.
- public static let onTargetBandMin: Double = 30.0
+ public static let onTargetBandMin: Double = minimumDebtMin
/// Sleep credited toward debt for one day. `mainSleepMin` remains the canonical
/// main-night total used by Rest and the sleep headline; separately-recorded naps
@@ -84,6 +89,41 @@ public enum SleepDebt {
return mainSleepMin + max(napSleepMin, 0)
}
+ /// Debt values oldest → newest for local fallback surfaces. The returned history is
+ /// not capped: each usable day's local value is independently recomputed from the
+ /// trailing `window` usable nights. An imported value wins verbatim for that day's
+ /// output only; it never seeds or replaces the local recurrence. Imported-only rows
+ /// are emitted but do not consume a usable-night slot. Rows with neither usable sleep
+ /// nor an imported value have no debt observation and are omitted.
+ public static func debtSeries(
+ series: [(day: String, totalSleepMin: Double?)],
+ needHours: Double = AnalyticsEngine.Rest.defaultNeedHours,
+ importedDebtMin: [String: Double] = [:],
+ window: Int = defaultWindowNights
+ ) -> [(day: String, value: Double)] {
+ let cap = max(window, 1)
+ var usableHistory: [(day: String, totalSleepMin: Double?)] = []
+ usableHistory.reserveCapacity(cap)
+ var result: [(day: String, value: Double)] = []
+ result.reserveCapacity(series.count)
+
+ for row in series {
+ let sleptMin = row.totalSleepMin.flatMap { $0 > 0 ? $0 : nil }
+ if let sleptMin {
+ usableHistory.append((row.day, sleptMin))
+ if usableHistory.count > cap { usableHistory.removeFirst() }
+ }
+
+ if let imported = importedDebtMin[row.day] {
+ result.append((row.day, imported))
+ } else if sleptMin != nil {
+ let debt = ledger(series: usableHistory, needHours: needHours, window: cap).magnitudeMin
+ result.append((row.day, debt))
+ }
+ }
+ return result
+ }
+
/// Build the ledger from a chronological `[(day, totalSleepMin?)]` series.
///
/// - Parameters:
@@ -97,9 +137,9 @@ public enum SleepDebt {
/// - window: how many of the most-recent COUNTED nights to include. Defaults to
/// `defaultWindowNights` (14). Clamped to ≥ 1.
///
- /// The balance is Σ over the window of (sleptMin − needMin): a surplus night
- /// offsets a deficit one. Returns an empty ledger (balance 0, no nights) when no
- /// night has usable data.
+ /// For each retained night, `nextDebt = 0.55 × max(0, need + debt − slept)`.
+ /// Results below ten minutes clear to zero. `SleepDebtNight.deltaMin` deliberately
+ /// remains the raw `slept − base need` value used by the per-night bars.
public static func ledger(series: [(day: String, totalSleepMin: Double?)],
needHours: Double = AnalyticsEngine.Rest.defaultNeedHours,
window: Int = defaultWindowNights) -> SleepDebtLedger {
@@ -113,14 +153,15 @@ public enum SleepDebt {
var nights: [SleepDebtNight] = []
nights.reserveCapacity(windowed.count)
- var balance = 0.0
+ var debt = 0.0
for row in windowed {
let slept = row.totalSleepMin ?? 0
let delta = slept - needMin
- balance += delta
+ let calculatedDebt = debtCarryFactor * max(0, needMin + debt - slept)
+ debt = calculatedDebt < minimumDebtMin ? 0 : calculatedDebt
nights.append(SleepDebtNight(day: row.day, sleptMin: slept, deltaMin: delta))
}
- return SleepDebtLedger(balanceMin: round1(balance), nights: nights, needMin: needMin)
+ return SleepDebtLedger(balanceMin: round1(-debt), nights: nights, needMin: needMin)
}
/// Round to 1 decimal place (the ledger is reported in whole/near-whole minutes;
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepNightLabel.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepNightLabel.swift
new file mode 100644
index 0000000000..48b675d9b3
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepNightLabel.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+/// How the Sleep hero names the night it is showing.
+///
+/// Extracted from `SleepView` so it can be tested. The Kotlin twin has lived in `SleepHeroLogic.kt`
+/// as a pure function with its own suite from the start; the Swift side kept it private inside the
+/// view, which is why a defect here went uncaught on this platform until a report arrived. This
+/// package's tests run in ordinary CI, unlike the app-target bundle, so the logic is verified on
+/// every change rather than only when the on-demand app build is dispatched.
+///
+/// Only the arithmetic lives here. The WORDING deliberately stays in the view: it returns a
+/// `LocalizedStringKey`, so the literals have to remain in source for extraction, and pulling them
+/// into a `String` helper here would quietly drop them out of the catalogue. The Kotlin twin can share
+/// both halves because Android localises through resource ids instead.
+///
+/// Kotlin twin: `calendarNightsAgo`.
+public enum SleepNightLabel {
+
+ /// How many nights back the carousel entry at `offset` is FROM TODAY.
+ ///
+ /// Measured from today, not from the newest recorded night. Anchoring on the newest record made
+ /// offset 0 always land on zero, so the hero read "Last night" over a night that could be days
+ /// old, printed directly above the correct date: two adjacent labels contradicting each other.
+ ///
+ /// `wakeTimestamps` is newest-first, one per carousel entry, each the entry's wake instant. It is
+ /// timestamps rather than sessions so this stays free of view types, and OPTIONAL so an entry with
+ /// no session falls back to the offset instead of being read as 1970.
+ ///
+ /// `today` must be the LOGICAL day (the 04:00 roll), and the wake instants must NOT be rolled.
+ /// The carousel groups nights by their calendar wake-date, so rolling that side too would let two
+ /// distinct entries collapse onto one label: a night ending 07:00 and the next ending 02:00 are
+ /// separate entries but the same logical day. Rolling only today is the half that matters, because
+ /// at 02:00 the night that ended yesterday morning is still "Last night".
+ ///
+ /// A NEGATIVE distance is normal, not a clock-skew guard: between waking before 04:00 and the
+ /// roll, the night's calendar date is already tomorrow relative to the logical day. Falling back
+ /// to the offset is the right answer there, so that branch carries a real case and must not be
+ /// narrowed to an error path.
+ public static func nightsAgo(
+ wakeTimestamps: [Int?],
+ offset: Int,
+ today: Date,
+ calendar: Calendar = .current
+ ) -> Int {
+ guard offset >= 0, offset < wakeTimestamps.count,
+ let shownTs = wakeTimestamps[offset] else { return offset }
+ let shown = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(shownTs)))
+ let todayStart = calendar.startOfDay(for: today)
+ let d = calendar.dateComponents([.day], from: shown, to: todayStart).day ?? offset
+ return d >= 0 ? d : offset
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift
index 659b9dabac..36118ee2e2 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift
@@ -15,6 +15,54 @@ extension SleepStager {
/// The gate-trace line formatters. Compact, parseable, no em-dashes.
public enum GateTrace {
+ /// Why the HR-only spine did or did not run (#1801). Twin of the Kotlin `hrOnlyGateLine`.
+ ///
+ /// The funnel line below only exists when the spine is CALLED, so its absence was ambiguous in
+ /// exactly the way that made the first field report unreadable: it could mean the gate never
+ /// opened, or that the sink was never wired. This says which, on any day with no motion — the
+ /// only days where the question arises.
+ public static func hrOnlyGateLine(attempted: Bool, reason: String,
+ gravRows: Int, storedNights: Int) -> String {
+ "[sleep] hr-only gate attempted=\(attempted) reason=\(reason) "
+ + "grav=\(gravRows) stored=\(storedNights)"
+ }
+
+ /// The HR-only spine's own funnel line (#1801). Byte-identical twin of the Kotlin
+ /// `SleepStagerTrace.hrOnlyLine`, so an Android and an Apple log of the same night compare
+ /// directly — which is the entire reason these formatters are twinned at all.
+ ///
+ /// The path shipped SILENT on both platforms, and a field log then showed `reason=no-motion`
+ /// with no way to tell whether the spine had run and found nothing or never ran. Every number
+ /// separates the two failures that actually happen: a band too tight (`sleepRuns` near zero)
+ /// from a duration gate eating real runs (`sleepRuns` high, `longestMin` under `minSleepMin`).
+ ///
+ /// `anchorBpm` and `bandBpm` are printed because they are DERIVED, not configured: the anchor is
+ /// a percentile of THIS window, so the same code gives every wearer a different threshold.
+ public static func hrOnlyLine(anchorBpm: Double?, bandBpm: Double?,
+ hrP50: Double?, hrP90: Double?, epochs: Int,
+ runs: Int, mergedRuns: Int, sleepRuns: Int,
+ longestSleepMin: Int, staged: Int, kept: Int,
+ minSleepMin: Int) -> String {
+ "[sleep] hr-only spine anchorBpm=\(round1(anchorBpm)) bandBpm=\(round1(bandBpm)) "
+ + "hrP50=\(round1(hrP50)) hrP90=\(round1(hrP90)) "
+ + "epochs=\(epochs) runs=\(runs) merged=\(mergedRuns) sleepRuns=\(sleepRuns) "
+ + "longestMin=\(longestSleepMin) staged=\(staged) kept=\(kept) minSleepMin=\(minSleepMin)"
+ }
+
+ /// One decimal, by ARITHMETIC rather than `String(format:)`. Twin of the Kotlin `round1`, and
+ /// see its note: `printf` rounds half-to-even on the binary value while Java's `String.format`
+ /// rounds HALF_UP on the decimal expansion, so the two disagreed on 64.05. This does the same
+ /// IEEE arithmetic on both platforms and cannot.
+ ///
+ /// NON-NEGATIVE ONLY, same as the Kotlin twin: `-0.5` would print "0.5", because the integer
+ /// division truncates toward zero and the sign is then lost to `abs`. Every current caller is a
+ /// bpm, which cannot be negative.
+ private static func round1(_ v: Double?) -> String {
+ guard let v else { return "nil" }
+ let tenths = Int((v * 10.0).rounded())
+ return "\(tenths / 10).\(abs(tenths % 10))"
+ }
+
/// One verdict line for a candidate run. `gate` names the constant that decided it
/// (minSleepMin, maxMainSleepSpanS, offWrist, daytimeGuard, morningStillness, hrConfirm,
/// sparseBridge, accepted); `detail` carries that gate's numbers. `startTs`/`endTs` give the
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
index 7be4760a44..4962a38fe9 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
@@ -50,16 +50,38 @@ public struct SleepSession: Equatable, Sendable {
public let restingHR: Int?
/// Mean RMSSD over 5-min windows across the session (ms), or nil.
public let avgHRV: Double?
+ /// Staged WITHOUT a motion spine, from heart rate alone (#1801).
+ ///
+ /// True only for a strap that streams HR but banks no motion, where Stage 0's gravity-stillness
+ /// spine has nothing to work with. Such a night is weaker by construction, not by tuning: with
+ /// motion gone a quiet evening at rest can sit in the sleep band. It is allowed to describe itself
+ /// — duration, stages, Rest — and must NOT reach anything it cannot be unwound from, which is why
+ /// `restingHR` and `avgHRV` are left nil on one rather than filtered out downstream.
+ ///
+ /// Kotlin twin: `DetectedSleep.hrOnly` (the model names diverge, `DetectedSleep`/`SleepSession`).
+ public let hrOnly: Bool
public init(start: Int, end: Int, efficiency: Double, stages: [StageSegment],
- restingHR: Int?, avgHRV: Double?) {
+ restingHR: Int?, avgHRV: Double?, hrOnly: Bool = false) {
self.start = start; self.end = end; self.efficiency = efficiency
self.stages = stages; self.restingHR = restingHR; self.avgHRV = avgHRV
+ self.hrOnly = hrOnly
}
}
public enum SleepStager {
+ // MARK: - #1943: RHR bin-gate thresholds (shared by sessionRestingHR and rhrBinGateLogLine)
+ // One constant per threshold, read by both the gate and its conformance check, so a drift
+ // between the two is structurally impossible rather than merely documented.
+
+ /// Minimum samples in a 5-min bin for it to qualify as a candidate for the night's resting HR.
+ /// A one-sample bin at the edge of a wear gap cannot become the floor.
+ public static let rhrMinBinSamples: Int = 5
+ /// Minimum plausible mean HR (bpm) for a bin to qualify. A dropout-driven sub-physiological
+ /// dip cannot become the floor.
+ public static let rhrMinPlausibleBpm: Double = 25
+
// MARK: - Stage 0 constants (sleep.py)
/// Per-sample gravity change (g) at/below which a sample is "still".
@@ -224,6 +246,44 @@ public enum SleepStager {
/// floor (a real continuous night never has a true >90 min wake bridge mid-sleep).
public static let sparseBridgeGapMin: Int = 90
+ /// A single intervening ACTIVE run up to this long may be absorbed when bridging two sleep runs
+ /// (#1657).
+ ///
+ /// This started as `maxGapMin` (20), on the reasoning that the file already had a threshold for
+ /// "a discontinuity this long is decisive". An end-to-end test through `detectSleep` showed that was
+ /// too tight to reach the case the issue is about: a FIFTEEN-minute interruption produced a
+ /// TWENTY-ONE-minute active run, because `classifyStill` smears the still/moving boundary by roughly
+ /// its rolling window and `buildRuns` closes runs at sample edges. The detected run is systematically
+ /// longer than the interruption it represents, so a bound set from the interruption's true length
+ /// rejects it.
+ ///
+ /// 30 is a judgement, and stated as one rather than dressed up as derived: a realistic trip out of
+ /// bed of up to about a quarter of an hour, plus the ~6 minutes of smear that measurement showed,
+ /// with a little headroom. It is deliberately well under `minSleepMin` — an interruption long enough
+ /// to be a session in its own right is a genuine awakening and should split the night.
+ ///
+ /// The bound is the cheap half of the guard. The real one is the HR band across the whole span: a
+ /// wearer who is actually up keeps HR elevated for the duration and fails it, while a brief stir does
+ /// not. `mergeMin` (15) already absorbs shorter active runs upstream in `mergePeriods`.
+ public static let sparseBridgeActiveMaxMin: Int = 30
+
+ /// The same bound when HR across the whole span stays in the sleep band.
+ ///
+ /// `sparseBridgeActiveMaxMin`'s own doc calls the minute bound "the cheap half of the guard" and the
+ /// HR band "the real one" — but the check order meant the cheap half vetoed first, so the real one
+ /// never got to speak for a run over 30 minutes. A 42-minute active run with sleep-band HR was
+ /// rejected identically to one with a wearer plainly up and about.
+ ///
+ /// That is the wrong way round on a strap whose "active" verdict comes from MOTION, which is the
+ /// sparse and unreliable signal on the hardware this fires for (field log 260901-1022: 20,647 gravity
+ /// samples across a 54-hour window, against 109,868 HR). HR is the better witness there, and it is
+ /// already computed. 60 rather than something larger because it is `minSleepMin`: an interruption
+ /// long enough to be a session in its own right is a genuine awakening whatever HR says.
+ ///
+ /// Applied as a MAXIMUM against `sparseBridgeActiveMaxMin`, never a replacement, so the in-band path
+ /// can only ever be more permissive — an out-of-band span keeps the 30-minute bound exactly.
+ public static let sparseBridgeActiveMaxInBandMin: Int = 60
+
// MARK: - Stage 1–3 constants (sleep_features.py)
public static let epochS: Double = 30.0
@@ -358,8 +418,15 @@ public enum SleepStager {
guard let baseline = baseline else { return false }
let seg = hr.filter { $0.ts > a && $0.ts <= b }
if seg.isEmpty { return false }
- let meanHR = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count)
- return meanHR <= baseline * hrSleepBandMult
+ // MEDIAN, not mean (#1657). `confirmSleepWithHR` below already documents why the mean is the
+ // wrong statistic here — "a real sleep night carries brief arousal / wake HR spikes (observed to
+ // ~190 bpm)" that drag it above the band — and uses the median for exactly that reason. This gate
+ // answers the same question over a SHORTER window, where a single spike dominates the mean even
+ // harder: a two-minute stir inside a fifteen-minute interval can put the mean out of band while
+ // the wearer was asleep for thirteen of those minutes. The median rejects a SUSTAINED elevation
+ // just as firmly, which is the discrimination this gate exists to make.
+ let medianHR = HRVAnalyzer.median(seg.map { Double($0.bpm) })
+ return medianHR <= baseline * hrSleepBandMult
}
/// Per-record sleep flags from a rolling fraction of "still" samples.
@@ -427,6 +494,234 @@ public enum SleepStager {
return periods
}
+ /// Percentile of the window's bpm that anchors the HR-only sleep band.
+ ///
+ /// NOT `hrBaseline`. That is the window MEDIAN, and it is the right anchor where it is used — as a
+ /// CONFIRMATION gate on a run gravity stillness already found, where being permissive is deliberate.
+ /// As a PRIMARY threshold it is disqualified by arithmetic rather than by tuning: a median splits the
+ /// samples in half by definition, so a band of `median * 1.05` admits strictly more than half of any
+ /// window whatever the data. Measured on a realistic 24 h (16 h awake 74-96, 8 h night 59-70) it
+ /// called 14.4 hours sleep against a truth of 8.
+ ///
+ /// A tenth percentile sits in the night's trough instead, which is what a sleep band should be
+ /// anchored to, and cannot admit half the window however the day is shaped.
+ public static let hrOnlyAnchorPercentile: Double = 0.10
+
+ /// Multiplier above `hrOnlyAnchorPercentile` that still counts as asleep.
+ ///
+ /// Numerically equal to `hrSleepBandMult` today, and deliberately a SEPARATE constant: that one is a
+ /// confirmation gate's tolerance and this one is a detector's, and a future change to either has no
+ /// business silently moving the other.
+ ///
+ /// Chosen conservatively from a sweep over anchor x multiplier against windows with known truth,
+ /// because the two failure directions are not symmetric. Over-detection puts a wrong Rest number on
+ /// screen; under-detection leaves "No data", which is the state this feature is trying to improve on
+ /// and therefore a safe place to fail. p10 x 1.05 measured 8.1 h against a truth of 8 on a
+ /// field-shaped 24 h window, and under-reads a long multi-night window rather than over-reading it
+ /// (8.7 h of 16 h across two nights in 54 h) — pinned in `SleepStagerHrOnlyAnchorTests`.
+ public static let hrOnlyBandMult: Double = 1.05
+
+ /// The `hrOnlyAnchorPercentile` of `hr` by bpm, or nil when empty. Nearest-rank (no interpolation), so
+ /// the value is always one the wearer actually recorded and the two platforms cannot disagree on a
+ /// rounding rule.
+ static func hrOnlyBaseline(_ hr: [HRSample]) -> Double? {
+ hrPercentile(hr, hrOnlyAnchorPercentile)
+ }
+
+ /// The `p` percentile of `hr` by bpm, nearest-rank. Shared with `hrOnlyBaseline` so the spread the
+ /// trace reports is measured by the SAME rule as the anchor it is meant to be judged against.
+ static func hrPercentile(_ hr: [HRSample], _ p: Double) -> Double? {
+ percentileOfSorted(hr.map { Double($0.bpm) }.sorted(), p)
+ }
+
+ /// The `p` percentile of an ALREADY-SORTED bpm list, nearest-rank. Split out because the caller
+ /// needs three percentiles from the same window, and the obvious spelling sorts once per
+ /// percentile — ~160k samples sorted three times per scored day across a 21-day rescore.
+ static func percentileOfSorted(_ sorted: [Double], _ p: Double) -> Double? {
+ if sorted.isEmpty { return nil }
+ let idx = min(max(Int(Double(sorted.count - 1) * p), 0), sorted.count - 1)
+ return sorted[idx]
+ }
+
+ /// How many distinct `hrOnlyEpochS` buckets `sortedByTs` spans.
+ ///
+ /// A single pass rather than a Set, because `hrS` is already sorted by timestamp so the bucket key
+ /// is non-decreasing. The obvious spelling (`Set(hrS.map { … })`) builds a full intermediate array
+ /// AND a set over every sample to end up with a few thousand distinct keys — per scored day, across
+ /// the 21-day rescore. A diagnostic must not cost what it is measuring.
+ static func distinctEpochs(_ sortedByTs: [HRSample]) -> Int {
+ var count = 0
+ var last = Int.min
+ for s in sortedByTs {
+ let key = s.ts / hrOnlyEpochS
+ if key != last { count += 1; last = key }
+ }
+ return count
+ }
+
+ /// Epoch for the HR-only spine, in seconds.
+ public static let hrOnlyEpochS: Int = 60
+
+ /// Sleep/active runs built from HEART RATE ALONE, for a strap that streams HR but banks no motion.
+ ///
+ /// Stage 0 is normally a gravity-stillness spine (Cole-Kripke) that HR only CONFIRMS, via
+ /// `hrSleepBandAcross` and `confirmSleepWithHR`. A WHOOP 5/MG that cannot bond never banks motion at
+ /// all — `SET_CLOCK` rides a handshake it never completes — so `grav` is empty, there is no spine, and
+ /// no quantity of HR can stage the night (#1801). This builds the spine from the one signal such a
+ /// strap does provide.
+ ///
+ /// Deliberately the SAME rule the confirm path already trusts: per-epoch MEDIAN bpm against
+ /// `baseline * hrSleepBandMult`, median for the reason `hrSleepBandAcross` spells out. The run
+ /// construction follows `buildRuns` — close on a class change or a gap over `maxGapMin` — so the two
+ /// spines segment alike once flags exist, and only the flag SOURCE differs.
+ ///
+ /// With ONE branch deliberately absent, and it is not an oversight. `buildRuns` can forgive a gap when
+ /// `hrSleepBandAcross` vouches that HR stayed in band across it, which rescues a night whose GRAVITY
+ /// dropped out. Here the gap IS in the heart rate, so there is nothing left to vouch with and no
+ /// analogue to port. A long HR dropout therefore breaks an HR-only run where it would not break a
+ /// motion-backed one, and a night fragmented that way is dropped by the caller's minimum-duration
+ /// gate rather than bridged.
+ ///
+ /// WEAKER THAN THE MOTION SPINE, by construction rather than by tuning. The file already notes that a
+ /// long still daytime stretch is gravity-indistinguishable from a nap and that HR is what saves it;
+ /// with motion gone the inverse is exposed, and a quiet evening at rest can sit in the sleep band. A
+ /// caller must treat these runs as lower-confidence than a motion-backed night and must not let one
+ /// reach a baseline it cannot be unwound from.
+ ///
+ /// Bucket order does not depend on sort stability: samples are grouped by epoch and reduced with a
+ /// median, so their order within an epoch cannot change the result.
+ static func hrOnlySleepRuns(_ hr: [HRSample], baseline: Double?,
+ epochS: Int = hrOnlyEpochS,
+ maxGapMinutes: Int = maxGapMin) -> [Period] {
+ guard let baseline = baseline, baseline > 0 else { return [] }
+ if hr.isEmpty || epochS <= 0 { return [] }
+ var byEpoch: [Int: [Double]] = [:]
+ var lastTs: [Int: Int] = [:]
+ for s in hr {
+ let k = s.ts / epochS
+ byEpoch[k, default: []].append(Double(s.bpm))
+ lastTs[k] = max(lastTs[k] ?? Int.min, s.ts)
+ }
+ let keys = byEpoch.keys.sorted()
+ // Two axes, deliberately. A gap and a run's START use the epoch's own start, so a gap is measured
+ // between epochs rather than between whichever samples sat at their edges. A run's END is the last
+ // SAMPLE observed in its final epoch, which is what `buildRuns` means by `end` — reading the epoch
+ // start there would report every run one whole epoch shorter than the data it covers, and that
+ // understatement would then be weighed against the caller's minimum-duration gate.
+ let times = keys.map { $0 * epochS }
+ let ends = keys.map { lastTs[$0]! }
+ let flags = keys.map { HRVAnalyzer.median(byEpoch[$0]!) <= baseline * hrOnlyBandMult }
+ let maxGapS = maxGapMinutes * 60
+ var periods: [Period] = []
+ var runStart = 0
+ for i in 1...keys.count {
+ let atEnd = (i == keys.count)
+ let close: Bool
+ if atEnd {
+ close = true
+ } else {
+ close = flags[i] != flags[runStart] || (times[i] - times[i - 1]) > maxGapS
+ }
+ if close {
+ periods.append(Period(stage: flags[runStart] ? "sleep" : "active",
+ start: times[runStart], end: ends[i - 1]))
+ runStart = i
+ }
+ }
+ return periods
+ }
+
+ /// Whole sleep SESSIONS from heart rate alone, for a strap that banks no motion (#1801).
+ ///
+ /// `hrOnlySleepRuns` supplies the spine this normally gets from gravity stillness; `SleepStagerV2`
+ /// then stages each surviving run from HR and R-R with an EMPTY gravity array. That is not a
+ /// degenerate call: V2's epoch features read HR and R-R directly and only its motion-quiescence terms
+ /// go quiet, so it returns a real hypnogram rather than one flat stage. A stageless session would be
+ /// dropped by `sleepSessionFromProvided` anyway, so a night that fails to stage is correctly omitted
+ /// here rather than passed on hollow.
+ ///
+ /// The anchor is `hrOnlyBaseline` — the `hrOnlyAnchorPercentile` of the window — and NOT the
+ /// `hrBaseline` median the motion path derives. Reusing that median looked like parity and was a bug:
+ /// as a confirmation gate on an already-detected run it is deliberately permissive, but as a primary
+ /// threshold it admits over half of any window by definition. See `hrOnlyAnchorPercentile`.
+ ///
+ /// `restingHR` and `avgHRV` are MEASURED here and reported (#1884). They were withheld under #1801,
+ /// which treated them as inferred; they are not. Only the session BOUNDS are inferred from heart rate
+ /// — that is what `hrOnly` marks — while each RMSSD is computed over its own 5-minute window, so fuzzy
+ /// bounds change WHICH windows are included, not whether any one of them is valid. Withholding them
+ /// discarded a measured 22-25 ms and left Charge with no input at all, which is a worse error than a
+ /// slightly fuzzy one.
+ ///
+ /// The flag travels on instead, so a consumer that wants to weigh an HR-only night down still can.
+ /// `public` because the app target calls it: `Strand/Data/IntelligenceEngine.swift` is the day scan,
+ /// and it lives outside this package. The spine and the anchor below it stay `internal` — the tests
+ /// reach them with `@testable`, and nothing outside should be building its own spine.
+ public static func hrOnlySessions(hr: [HRSample], rr: [RRInterval], resp: [RespSample],
+ minMinutes: Int = minSleepMin,
+ traceSink: ((String) -> Void)? = nil) -> [SleepSession] {
+ let hrS = hr.sorted { $0.ts < $1.ts }
+ // ONE sort of the bpm axis, reused for the anchor and for the spread the trace reports.
+ let sortedBpm = hrS.map { Double($0.bpm) }.sorted()
+ guard let baseline = percentileOfSorted(sortedBpm, hrOnlyAnchorPercentile) else {
+ traceSink?(GateTrace.hrOnlyLine(anchorBpm: nil, bandBpm: nil, hrP50: nil, hrP90: nil,
+ epochs: 0, runs: 0,
+ mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0,
+ staged: 0, kept: 0, minSleepMin: minMinutes))
+ return []
+ }
+ let rrS = rr.sorted { $0.ts < $1.ts }
+ var out: [SleepSession] = []
+ // mergePeriods for the same reason the motion path calls it: a run boundary is a threshold
+ // crossing, and a sleeping heart rate oscillates across the band all night. Without this the
+ // spine returns the night's minutes correctly but shredded into sub-mergeMin fragments, every one
+ // of which then fails the minimum-duration gate below — 8 h of detected sleep yielding zero
+ // sessions. Absorbing the short runs first is what turns a spine into a night.
+ let rawRuns = hrOnlySleepRuns(hrS, baseline: baseline)
+ let merged = mergePeriods(rawRuns)
+ var staged = 0
+ var longestSleepS = 0
+ for p in merged {
+ if p.stage != "sleep" { continue }
+ longestSleepS = max(longestSleepS, p.end - p.start)
+ if (p.end - p.start) < minMinutes * 60 { continue }
+ let stages = SleepStagerV2.stageSession(start: p.start, end: p.end, grav: [],
+ hr: hrS, rr: rrS, resp: resp)
+ staged += 1
+ if stages.isEmpty { continue }
+ // #1884: MEASURED, not nilled. The bounds here are inferred from heart rate, which is why
+ // `hrOnly` marks the session — but each RMSSD is computed over its own 5-minute window tagged
+ // by the stage at its centre, so fuzzy bounds change WHICH windows are included, not whether
+ // any one of them is valid. A field log showed this path computing a stable 22-25ms deepOnly
+ // and discarding it, leaving Charge unscoreable. `hrOnly` still travels with the session, so
+ // it is a quality marker now rather than a delete. Kotlin twin: `SleepStager.hrOnlySessions`.
+ out.append(SleepSession(start: p.start, end: p.end,
+ efficiency: efficiency(start: p.start, end: p.end, stages: stages),
+ stages: stages,
+ restingHR: sessionRestingHR(start: p.start, end: p.end, hr: hrS),
+ avgHRV: sessionAvgHRV(start: p.start, end: p.end, rr: rrS),
+ hrOnly: true))
+ }
+ traceSink?(GateTrace.hrOnlyLine(
+ anchorBpm: baseline,
+ bandBpm: baseline * hrOnlyBandMult,
+ // The wearer's own spread. An anchor alone cannot be judged: p10 of 60 means one thing when
+ // the median is 63 and quite another when it is 74, and only the second leaves a night the
+ // band can separate.
+ hrP50: percentileOfSorted(sortedBpm, 0.50),
+ hrP90: percentileOfSorted(sortedBpm, 0.90),
+ // The real epoch count, not the sample count: the spine buckets by `hrOnlyEpochS` before it
+ // decides anything, so this is the axis every other number here is measured on.
+ epochs: distinctEpochs(hrS),
+ runs: rawRuns.count,
+ mergedRuns: merged.count,
+ sleepRuns: merged.filter { $0.stage == "sleep" }.count,
+ longestSleepMin: longestSleepS / 60,
+ staged: staged,
+ kept: out.count,
+ minSleepMin: minMinutes))
+ return out
+ }
+
/// Absorb runs shorter than mergeMin minutes into their neighbours.
static func mergePeriods(_ periods: [Period], mergeMinutes: Int = mergeMin) -> [Period] {
if periods.isEmpty { return [] }
@@ -472,11 +767,15 @@ public enum SleepStager {
struct SparseBridgeAttempt: Equatable, Sendable {
/// Gap between the two runs, in whole minutes (negative when they overlap).
let gapMin: Int
+ /// Minutes of intervening ACTIVE run, or 0 when the pair was only separated by a gap (#1657).
+ let activeMin: Int
+ /// The bound this pair was actually judged against — 30, or 60 when HR stayed in the sleep band.
+ let activeCapMin: Int
/// Whether the intervening HR stayed in the sleep band (the bridge's second condition).
let hrInSleepBand: Bool
/// Whether this pair was merged.
let bridged: Bool
- /// Stable token for the log: bridged / gapTooLong / hrOutOfBand / overlap.
+ /// Stable token for the log: bridged / gapTooLong / hrOutOfBand / overlap / activeTooLong.
let reason: String
}
@@ -485,57 +784,101 @@ public enum SleepStager {
/// actually happened rather than an approximation. Only pairs the bridge itself CONSIDERS (two
/// adjacent sleep runs) produce an attempt; a pair separated by an active run is never considered,
/// which is itself the answer when no attempts are reported. Pure — no I/O, no side effects.
- static func sparseBridgeAttempts(_ periods: [Period], sparse: Bool,
- hr: [HRSample], baseline: Double?) -> [SparseBridgeAttempt] {
- guard sparse, !periods.isEmpty else { return [] }
+ /// The bridge, plus what it considered. See `bridgeSparseSleep` for the merge rule.
+ ///
+ /// #1657: an intervening ACTIVE run no longer blocks the merge permanently. The original loop could
+ /// only join runs already adjacent in its own output, so any active run between two sleep runs was
+ /// appended first and made the next pair unreachable — and a field trace found the bridge merging
+ /// NOTHING on 14 of 14 sparse nights for exactly that reason. Since a bathroom trip is definitionally
+ /// an active run, the rescue built for fragmentation was unavailable in the case that needs it most.
+ ///
+ /// A single active run up to `sparseBridgeActiveMaxMin` is now absorbed, with the whole span still
+ /// subject to `sparseBridgeGapMin` and to the HR band. Two or more consecutive active runs are not:
+ /// that is a night with real structure in it, not one interruption.
+ ///
+ /// This USED to be a shadow copy of the loop kept only for tracing, which had to be edited in step
+ /// with the real one — a trace that quietly disagrees with the behaviour it describes is worse than
+ /// no trace. Merge and trace are one pass now. Kotlin twin: `bridgeSparseSleepTraced`.
+ static func bridgeSparseSleepTraced(_ periods: [Period], sparse: Bool, hr: [HRSample],
+ baseline: Double?) -> ([Period], [SparseBridgeAttempt]) {
+ if !sparse || periods.isEmpty { return (periods, []) }
let bridgeGapS = sparseBridgeGapMin * 60
- var attempts: [SparseBridgeAttempt] = []
+ let activeMaxS = sparseBridgeActiveMaxMin * 60
+ let activeMaxInBandS = sparseBridgeActiveMaxInBandMin * 60
var out: [Period] = []
+ var attempts: [SparseBridgeAttempt] = []
+
+ /// Judge one candidate pair, record it, and merge when it passes.
+ ///
+ /// The order of the checks fixes which reason a failing pair reports, and it is deliberate:
+ /// overlap and gap are properties of the pair, activeTooLong is a property of what sits between
+ /// them, and hrOutOfBand is last because it is the only one that needed the HR series to decide.
+ /// `activeMaxInBandS` is a PARAMETER rather than a capture, mirroring the Kotlin default of 0.
+ /// Captured, case 1 (adjacent sleep runs, no intervening active run) would report activeCapMin=60
+ /// on Apple and 0 on Android for the identical decision — same behaviour, divergent trace, which
+ /// is exactly the byte-for-byte comparison these lines exist to allow.
+ func consider(_ left: Period, _ right: Period, activeS: Int,
+ dropTrailing: Bool, activeMaxS: Int, activeMaxInBandS: Int = 0) -> Bool {
+ let gap = right.start - left.end
+ let inBand = hrSleepBandAcross(left.end, right.start, hr: hr, baseline: baseline)
+ // The HR band is the real guard, so let it widen the minute bound rather than be vetoed by
+ // it. max, not a swap: in-band can only ever be MORE permissive, and case 1 (activeMaxS = 0,
+ // no intervening run) is untouched because activeS is 0 there too.
+ let activeCapS = inBand ? max(activeMaxS, activeMaxInBandS) : activeMaxS
+ let reason: String
+ if gap < 0 { reason = "overlap" }
+ else if gap > bridgeGapS { reason = "gapTooLong" }
+ else if activeS > activeCapS { reason = "activeTooLong" }
+ else if !inBand { reason = "hrOutOfBand" }
+ else { reason = "bridged" }
+ let bridged = reason == "bridged"
+ attempts.append(SparseBridgeAttempt(gapMin: gap / 60, activeMin: activeS / 60,
+ activeCapMin: activeCapS / 60,
+ hrInSleepBand: inBand, bridged: bridged, reason: reason))
+ guard bridged else { return false }
+ if dropTrailing { out.removeLast() }
+ out[out.count - 1] = Period(stage: "sleep", start: left.start, end: right.end)
+ return true
+ }
+
for p in periods {
- if let last = out.last, last.stage == "sleep", p.stage == "sleep" {
- let gap = p.start - last.end
- let inBand = hrSleepBandAcross(last.end, p.start, hr: hr, baseline: baseline)
- let bridged = gap >= 0 && gap <= bridgeGapS && inBand
- let reason: String
- if bridged { reason = "bridged" }
- else if gap < 0 { reason = "overlap" }
- else if gap > bridgeGapS { reason = "gapTooLong" }
- else { reason = "hrOutOfBand" }
- attempts.append(SparseBridgeAttempt(gapMin: gap / 60, hrInSleepBand: inBand,
- bridged: bridged, reason: reason))
- if bridged {
- out[out.count - 1] = Period(stage: "sleep", start: last.start, end: p.end)
- continue
+ if p.stage == "sleep" {
+ // Case 1: the previous run is sleep — the original adjacent-pair merge.
+ if let last = out.last, last.stage == "sleep" {
+ if consider(last, p, activeS: 0, dropTrailing: false, activeMaxS: 0) { continue }
+ }
+ // Case 2: exactly one active run sits between two sleep runs. Absorbed when short
+ // enough, which is the #1657 case the original loop could never reach.
+ if out.count >= 2, let last = out.last, last.stage == "active",
+ out[out.count - 2].stage == "sleep" {
+ let prev = out[out.count - 2]
+ if consider(prev, p, activeS: last.end - last.start,
+ dropTrailing: true, activeMaxS: activeMaxS,
+ activeMaxInBandS: activeMaxInBandS) { continue }
}
}
out.append(p)
}
- return attempts
+ return (out, attempts)
}
- /// Sparse-gravity bridge (#308): merge two adjacent SLEEP runs separated ONLY by a gap up to
- /// sparseBridgeGapMin minutes when the intervening HR stays in the sleep band — so a real night
- /// fragmented by gravity dropouts is re-stitched into one continuous in-bed span BEFORE the
- /// minSleepMin gate drops the pieces. Active runs and over-threshold gaps are left untouched;
- /// the span between two bridged sleep runs (an "active"/gap run, if present) is absorbed.
- /// A no-op when `sparse == false`, so the dense 4.0 path is unchanged.
+ /// Sparse-gravity bridge (#308): merge two SLEEP runs when the intervening HR stays in the sleep
+ /// band — so a real night fragmented by gravity dropouts is re-stitched into one continuous in-bed
+ /// span BEFORE the minSleepMin gate drops the pieces. A no-op when `sparse == false`, so the dense
+ /// 4.0 path is unchanged.
+ ///
+ /// What sits between the two runs may be a bare gap (up to `sparseBridgeGapMin`) or ONE active run
+ /// (additionally up to `sparseBridgeActiveMaxMin`). Over-threshold gaps, longer active runs and two
+ /// consecutive active runs are all left untouched.
+ ///
+ /// The previous wording here — "Active runs … are left untouched; the span between two bridged sleep
+ /// runs (an "active"/gap run, if present) is absorbed" — read as though an intervening active run was
+ /// already handled. It was not: only a bare gap was, and that sentence is a large part of why #1657
+ /// went unnoticed. Kept in the history rather than quietly deleted, because the next person to widen
+ /// this function will read this comment first.
static func bridgeSparseSleep(_ periods: [Period], sparse: Bool,
hr: [HRSample], baseline: Double?) -> [Period] {
- if !sparse || periods.isEmpty { return periods }
- let bridgeGapS = sparseBridgeGapMin * 60
- var out: [Period] = []
- for p in periods {
- if let last = out.last, last.stage == "sleep", p.stage == "sleep" {
- let gap = p.start - last.end
- if gap >= 0 && gap <= bridgeGapS
- && hrSleepBandAcross(last.end, p.start, hr: hr, baseline: baseline) {
- out[out.count - 1] = Period(stage: "sleep", start: last.start, end: p.end)
- continue
- }
- }
- out.append(p)
- }
- return out
+ bridgeSparseSleepTraced(periods, sparse: sparse, hr: hr, baseline: baseline).0
}
// MARK: - HR refinement
@@ -1048,9 +1391,9 @@ public enum SleepStager {
let runsBeforeBridge = traceSink == nil ? 0 : runs.filter { $0.stage == "sleep" }.count
// #737: capture the per-pair reasons BEFORE the merge mutates `runs`, so a bridge that changed
// nothing still says why (gapTooLong / hrOutOfBand / overlap) instead of only before==after.
- let bridgeAttempts = traceSink == nil ? [] : sparseBridgeAttempts(runs, sparse: sparse, hr: hrS,
- baseline: baseline)
- runs = bridgeSparseSleep(runs, sparse: sparse, hr: hrS, baseline: baseline)
+ let bridgeResult = bridgeSparseSleepTraced(runs, sparse: sparse, hr: hrS, baseline: baseline)
+ let bridgeAttempts = bridgeResult.1
+ runs = bridgeResult.0
// Sleep & Rest test mode (E3): record the sparse-gravity bridge result, so a sparse 5.0 night
// rescued from fragmentation is visible. Only emitted when gravity is sparse (the only case the
// bridge can act) and only when tracing. Side-effect-only.
@@ -1059,19 +1402,24 @@ public enum SleepStager {
traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0,
verdict: runsAfterBridge < runsBeforeBridge ? .kept : .dropped, gate: "sparseBridge",
detail: "sparse=true gapMin=\(sparseBridgeGapMin) runsBefore=\(runsBeforeBridge) runsAfter=\(runsAfterBridge)"))
- // #737: one line per pair the bridge CONSIDERED. No lines at all means no two adjacent sleep
- // runs were ever seen — i.e. the fragments are separated by active runs, which the bridge
- // deliberately never crosses. That absence is itself the diagnosis.
+ // #737: one line per pair the bridge CONSIDERED, each naming what it decided.
+ //
+ // #1657 changed what an empty list MEANS, so the wording changed with it. It used to mean
+ // "the fragments are separated by active runs", because such a pair could never be reached —
+ // that absence was the diagnosis. A single short active run is now a considered pair, so an
+ // empty list can only mean there was no candidate at all: one sleep run, or fragments split
+ // by two or more consecutive active runs. Leaving the old text would have pointed the next
+ // reader at a cause that had just been removed.
if bridgeAttempts.isEmpty {
traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0, verdict: .dropped,
gate: "sparseBridge",
- detail: "no adjacent sleep pairs considered (fragments separated by active runs)"))
+ detail: "no candidate pairs (one sleep run, or fragments split by consecutive active runs)"))
}
for (i, a) in bridgeAttempts.enumerated() {
traceSink(GateTrace.runLine(index: -1, startTs: 0, endTs: 0,
verdict: a.bridged ? .kept : .dropped, gate: "sparseBridgePair",
- detail: "pair=\(i) gapMin=\(a.gapMin) hrInSleepBand=\(a.hrInSleepBand) "
- + "reason=\(a.reason)"))
+ detail: "pair=\(i) gapMin=\(a.gapMin) activeMin=\(a.activeMin) activeCapMin=\(a.activeCapMin) "
+ + "hrInSleepBand=\(a.hrInSleepBand) reason=\(a.reason)"))
}
}
@@ -2436,18 +2784,101 @@ public enum SleepStager {
// MARK: - Per-session HR / HRV
/// Lowest 5-min rolling-mean HR during the session (bpm), or nil.
+ ///
+ /// The window is CLOSED at both ends, so the binning is too: bins are `[t, t + windowS)` except
+ /// the final one, which is `[t, end]`. Half-open bins alone would admit a sample sitting exactly
+ /// on an aligned `end` through the prefilter and then place it in no bin — counted as data,
+ /// silently ignored. A zero-length window (`start == end`) is that single closed bin.
+ /// #1943: a conformance check that reports when the artefact gate `sessionRestingHR` applies
+ /// actually MOVED the floor. The shipped floor IS the gated floor, so comparing the gated floor
+ /// against it is silent by construction. Instead, this reports the UNGATED floor (the old rule:
+ /// min over every non-empty bin) against the shipped one, so the line fires when the gate
+ /// excluded the bin that would otherwise have won — which is exactly the frequency and magnitude
+ /// the measure-only diagnostic was meant to learn, and would never have reported otherwise.
+ ///
+ /// `sessionRestingHR` gates bins on `rhrMinBinSamples` and `rhrMinPlausibleBpm` before letting them
+ /// win the floor, falling back to the lowest of all bin means when no bin qualifies. This helper
+ /// reproduces the same partition and the same gate, and ALSO computes the ungated floor (min over
+ /// every non-empty bin, the pre-#1943 rule) so the two can be compared.
+ ///
+ /// Bins are built exactly as `sessionRestingHR` builds them, closed final bin included, or the line
+ /// would describe a different partition than the one it is judging.
+ ///
+ /// Returns nil unless the gate actually MOVED the floor (ungated != shipped), so the log carries
+ /// only the nights the gate did something. If it turns out to fire on one night in fifty, that is
+ /// worth knowing; if it fires nightly, that is worth knowing sooner.
+ ///
+ /// The counts still ride along when the line does fire, since they are the context for the change.
+ /// Same posture as the over-count-only R-R dump. Counts and bpm only, no timestamps. Pure. Twin of
+ /// Kotlin `rhrBinGateLogLine`.
+ public static func rhrBinGateLogLine(day: String, sessions: [(Int, Int)], hr: [HRSample],
+ shippedFloor: Int, minBinSamples: Int = rhrMinBinSamples,
+ minPlausibleBpm: Double = rhrMinPlausibleBpm) -> String? {
+ let windowS = 5 * 60
+ var bins = 0, thin = 0, implausible = 0, ungatedN = 0
+ var ungated: Double?
+ var gated: Double?
+ for (start, end) in sessions {
+ let seg = hr.filter { $0.ts >= start && $0.ts <= end }
+ if seg.isEmpty { continue }
+ var t = start
+ repeat {
+ let isFinal = t + windowS >= end
+ let win = seg.filter { $0.ts >= t && (isFinal || $0.ts < t + windowS) }
+ if !win.isEmpty {
+ bins += 1
+ let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count)
+ if win.count < minBinSamples { thin += 1 }
+ if mean < minPlausibleBpm { implausible += 1 }
+ if ungated == nil || mean < ungated! { ungated = mean; ungatedN = win.count }
+ if win.count >= minBinSamples, mean >= minPlausibleBpm,
+ gated == nil || mean < gated! { gated = mean }
+ }
+ t += windowS
+ } while t < end
+ }
+ if bins == 0 { return nil }
+ let ungatedFloor = ungated.map { Int($0.rounded()) }
+ // The gate moved the floor when the ungated floor differs from the shipped one. The shipped
+ // floor IS the gated floor, so this fires when the gate excluded the bin that would have won
+ // under the old rule — which is the frequency and magnitude we want to learn.
+ let moved = ungatedFloor != nil && ungatedFloor != shippedFloor
+ if !moved { return nil }
+ let gatedFloor = gated.map { Int($0.rounded()) }
+ return "rhr bins day=\(day) bins=\(bins) thin=\(thin) implausible=\(implausible) "
+ + "winnerN=\(ungatedN) ungated=\(ungatedFloor.map(String.init) ?? "nil") "
+ + "gated=\(gatedFloor.map(String.init) ?? "nil") shipped=\(shippedFloor) gateMoved=\(moved)"
+ }
+
static func sessionRestingHR(start: Int, end: Int, hr: [HRSample]) -> Int? {
let seg = hr.filter { $0.ts >= start && $0.ts <= end }
guard !seg.isEmpty else { return nil }
let windowS = 5 * 60
- var means: [Double] = []
+ // #1943: a bin qualifies to WIN the floor only when it is well-populated (≥ rhrMinBinSamples)
+ // and its mean is physiologically plausible (≥ rhrMinPlausibleBpm). A one-sample bin at the
+ // edge of a wear gap, or a dropout-driven sub-physiological dip, cannot become the night's
+ // resting HR — that number is displayed, stored on the daily row, and fed to the baseline
+ // later nights are scored against. If no bin qualifies, fall back to the lowest of ALL bin
+ // means (ungated), then the all-sample mean — preserving the never-null-on-data behaviour.
+ var gatedMeans: [Double] = []
+ var allMeans: [Double] = []
var t = start
- while t < end {
- let win = seg.filter { $0.ts >= t && $0.ts < t + windowS }
- if !win.isEmpty { means.append(Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count)) }
+ repeat {
+ // The last bin (its half-open end reaches or passes `end`) closes on `end` instead,
+ // catching an endpoint sample the prefilter already admitted. `seg` holds nothing past
+ // `end`, so "everything from t onwards" IS [t, end]. `repeat` runs once for a
+ // zero-length window, where that single closed bin is the whole window.
+ let isFinal = t + windowS >= end
+ let win = seg.filter { $0.ts >= t && (isFinal || $0.ts < t + windowS) }
+ if !win.isEmpty {
+ let mean = Double(win.reduce(0) { $0 + $1.bpm }) / Double(win.count)
+ allMeans.append(mean)
+ if win.count >= rhrMinBinSamples && mean >= rhrMinPlausibleBpm { gatedMeans.append(mean) }
+ }
t += windowS
- }
- if let m = means.min() { return Int(m.rounded()) }
+ } while t < end
+ if let m = gatedMeans.min() { return Int(m.rounded()) }
+ if let m = allMeans.min() { return Int(m.rounded()) }
let all = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count)
return Int(all.rounded())
}
@@ -2465,12 +2896,42 @@ public enum SleepStager {
/// Uses the same range-filter + ≥2-valid-interval rule as hrv.rmssd().
static func sessionAvgHRV(start: Int, end: Int, rr: [RRInterval]) -> Double? {
let vals = sessionHrvWindows(start: start, end: end, rr: rr, stages: []).compactMap { $0.rmssd }
- return vals.isEmpty ? nil : vals.reduce(0, +) / Double(vals.count)
+ if vals.isEmpty { return nil }
+ // #1118: refuse the night outright when its own R-R banks more beat-time than the wall clock it
+ // spans. Gated HERE rather than at the caller because this is where RMSSD BECOMES the day's HRV:
+ // one seam covers the daily row, the sleep-session cache, the Health card and the baseline that
+ // later nights are scored against, so none of them can end up disagreeing about whether the night
+ // was trustworthy. See `HRVAnalyzer.successiveDiffIsTrustworthy` for why an over-count corrupts a
+ // successive-difference statistic and why a blank is the right answer.
+ //
+ // Classified over the SAME beats the value was built from, windowed [start, end] exactly as
+ // `sessionHrvWindows` does, so the verdict cannot describe a different set of beats than the number
+ // it is gating.
+ let seg = rr.filter { $0.ts >= start && $0.ts <= end }
+ let segTs = seg.map { $0.ts }
+ let segMs = seg.map { Double($0.rrMs) }
+ let coverage = HRVAnalyzer.rrCoverage(tsSec: segTs, rrMs: segMs)
+ // `collapsed` is deliberately the SAME figure as `coverage`, which pins every over-count here to
+ // crossSecondOverCount. That is not a claim about which kind it is. The collapsed figure exists only
+ // to choose BETWEEN the two over-count verdicts, and this gate refuses both, so the real one would
+ // change no outcome — while costing a full sort of the night's ~50-70k beats, since
+ // `collapsedCoverage` opens with a sort. This runs per session, per day, across ~21 days of every
+ // analyzeRecent, every 15 minutes; #1510 cut this exact path from six sorts a night to two, and
+ // buying a distinction the caller discards would hand that back. `rrCoverage` is a single O(n)
+ // pass. If a future gate ever needs the two over-count cases apart, compute it then.
+ let verdict = HRVAnalyzer.classifyCoverage(coverage: coverage, collapsed: coverage)
+ guard HRVAnalyzer.successiveDiffIsTrustworthy(verdict) else { return nil }
+ return vals.reduce(0, +) / Double(vals.count)
}
/// Per-5-min-window RMSSD across a session, each window tagged with the sleep stage at its CENTER
/// (from `stages`) — the SINGLE source `sessionAvgHRV` averages, and the HRV nightly trace reads.
/// Passing `[]` for `stages` tags every window "?" (the plain-average path needs no stages). (#141)
+ ///
+ /// Windows follow the same closed-window rule as `sessionRestingHR`: `[t, t + windowS)` except
+ /// the final one, which is `[t, end]`, so a beat sitting exactly on an aligned `end` lands in a
+ /// window instead of being admitted by the prefilter and then dropped. Window stage tagging and
+ /// `startTs` are unchanged — the final window keeps its half-open center `t + windowS / 2`.
static func sessionHrvWindows(start: Int, end: Int, rr: [RRInterval], stages: [StageSegment]) -> [HrvWindow] {
// CONTRACT: `rr` MUST already be ts-sorted (RMSSD is built from SUCCESSIVE differences, so a bucket
// has to be chronological). The value path passes the loop's pre-sorted `rrS`; the trace caller sorts
@@ -2481,8 +2942,12 @@ public enum SleepStager {
let windowS = 5 * 60
var out: [HrvWindow] = []
var t = start
- while t < end {
- let bucket = seg.filter { $0.ts >= t && $0.ts < t + windowS }.map { Double($0.rrMs) }
+ repeat {
+ // Final window closes on `end` — same closed-window rule as sessionRestingHR, so an
+ // endpoint beat counts instead of vanishing after admission. `repeat` runs once for a
+ // zero-length window, where that single closed window is the whole session.
+ let isFinal = t + windowS >= end
+ let bucket = seg.filter { $0.ts >= t && (isFinal || $0.ts < t + windowS) }.map { Double($0.rrMs) }
// Full clean (range + Malik ectopic rejection), not just range — matches the
// analyze() pipeline. The 0x2A37 RR on a WHOOP 5/MG is PPG-derived and noisier
// than a 4.0's; rMSSD is built from SUCCESSIVE differences, so an un-rejected
@@ -2495,7 +2960,7 @@ public enum SleepStager {
let stage = stages.first { center >= $0.start && center < $0.end }?.stage ?? "?"
out.append(HrvWindow(startTs: t, stage: stage, cleanBeats: cleaned.nn.count, rmssd: rmssd))
t += windowS
- }
+ } while t < end
return out
}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SlidingStreamWindow.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SlidingStreamWindow.swift
new file mode 100644
index 0000000000..fa73ae5dbf
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SlidingStreamWindow.swift
@@ -0,0 +1,129 @@
+import Foundation
+
+/// One stream's backward sliding read buffer for `analyzeRecent`'s pass-1 loop (#1538).
+///
+/// `WindowedStreamPlan` decides WHETHER a day's window can reuse what the previous day read; this holds
+/// the rows and does the splice. Kept out of the engine because the splice is the part that can silently
+/// be wrong — a row dropped here is a scoring input that vanishes — so it gets its own tests rather than
+/// living inline in a very long loop. Twin of Kotlin `SlidingStreamWindow`.
+///
+/// The buffer always ends up covering EXACTLY the window just served, never more: the walk runs backwards
+/// so the tail above `to` is never asked for again, and holding it would grow the footprint across the
+/// pass instead of keeping it at one window. Rows are stored in the store's own `ts ASC` order and an
+/// extension is always strictly BELOW the buffer, so prepending preserves that order without a sort.
+public final class SlidingStreamWindow {
+
+ private let tsOf: (T) -> Int
+ private let limit: Int
+ /// The store call for `(owner, from, to)`. Bound at CONSTRUCTION to match the Kotlin twin, where the
+ /// two call sites sit inside a method already at its JVM bytecode budget and a per-call lambda put it
+ /// over. It must return `ts ASC` and honour the same `limit`.
+ ///
+ /// `nil` means the read FAILED, which is not the same as returning no rows and must never be cached as
+ /// if it were. An empty successful read is a true statement about a range — the buffer can serve it,
+ /// and the next day may splice against it. An empty FAILED read is no statement at all, and caching it
+ /// would let the following day splice against rows nobody ever fetched, silently dropping the part of
+ /// its window the buffer claimed to hold. This side is the one that needs it: the engine wraps its
+ /// store reads in `try?`, so a failure arrives here as an empty array unless the caller says otherwise.
+ private let read: (String, Int, Int) async -> [T]?
+
+ private var owner: String?
+ private var from = 0
+ private var to = 0
+ private var truncated = false
+ private var rows: [T] = []
+
+ /// Rows this window served from the buffer rather than reading. Diagnostic only.
+ ///
+ /// `Int` here, `Long` on the Kotlin twin, and that is deliberate rather than drift: each side follows
+ /// its own platform's convention for a count, and the value cannot reach either limit — a pass is
+ /// bounded by `maxDays` windows of at most `limit` rows, so about 4 M on a 21-day pass, which fits
+ /// even the 32-bit `Int` of `arm64_32` that this package also builds for. Stated because a width left
+ /// to be inferred is how the 32-bit `pct` over-count got in (#1685).
+ public private(set) var rowsServed = 0
+ /// Rows this window read from the store. Diagnostic only. Same width note as `rowsServed`.
+ public private(set) var rowsRead = 0
+ /// Reads whose RESULT came back at the store's cap, so the newest rows were dropped and the day was
+ /// scored on an incomplete window. Diagnostic only, but unlike its siblings this one is a correctness
+ /// signal rather than a savings one: a non-zero count means a number may be wrong, not merely slow.
+ ///
+ /// Counted per WINDOW that lost rows, not per pass: once a read is truncated the planner refuses to
+ /// splice at all, so each later window is a fresh full read and is judged on its own. The truncated-
+ /// EXTENSION branch below cannot add a second count for one window either, since it only ever re-reads
+ /// a SUPERSET of what already overran the cap. Pinned by `eachTruncatedWindowCountsSeparately`.
+ public private(set) var truncatedReads = 0
+
+ public init(tsOf: @escaping (T) -> Int, limit: Int, read: @escaping (String, Int, Int) async -> [T]?) {
+ self.tsOf = tsOf
+ self.limit = limit
+ self.read = read
+ }
+
+ /// The rows for `[from, to]` under `owner`, reading as little as the plan allows.
+ ///
+ /// The result is byte-for-byte what a direct `read(owner, from, to)` would have returned — that is the
+ /// whole contract, and the reason every case the planner cannot prove falls back to exactly that call.
+ public func rows(owner: String, from: Int, to: Int) async -> [T] {
+ let plan = WindowedStreamPlan.plan(cachedOwner: self.owner, cachedFrom: self.from,
+ cachedTo: self.to, cachedTruncated: truncated,
+ owner: owner, from: from, to: to)
+ let result: [T]
+ let nowTruncated: Bool
+ switch plan {
+ case .serve:
+ result = rows.filter { tsOf($0) >= from && tsOf($0) <= to }
+ nowTruncated = false
+ rowsServed += result.count
+ case let .extend(readFrom, readTo):
+ guard let head = await read(owner, readFrom, readTo) else { return failedRead() }
+ rowsRead += head.count
+ if head.count >= limit {
+ // A truncated EXTENSION is worse than a truncated buffer: it would leave a hole in the
+ // middle rather than at the end. Discard and read the window whole.
+ guard let full = await read(owner, from, to) else { return failedRead() }
+ rowsRead += full.count
+ result = full
+ nowTruncated = full.count >= limit
+ } else {
+ // Filtered into ONE array rather than `(head + rows).filter { }`: that form allocates
+ // the concatenation AND the filtered copy, so the splice transiently held roughly three
+ // windows' worth of references. Same result, one allocation.
+ var out: [T] = []
+ out.reserveCapacity(head.count + rows.count)
+ for r in head where tsOf(r) >= from && tsOf(r) <= to { out.append(r) }
+ for r in rows where tsOf(r) >= from && tsOf(r) <= to { out.append(r) }
+ result = out
+ nowTruncated = false
+ rowsServed += max(0, result.count - head.count)
+ }
+ case .fullRead:
+ guard let full = await read(owner, from, to) else { return failedRead() }
+ rowsRead += full.count
+ result = full
+ nowTruncated = full.count >= limit
+ }
+ self.owner = owner
+ self.from = from
+ self.to = to
+ self.truncated = nowTruncated
+ if nowTruncated { truncatedReads += 1 }
+ self.rows = result
+ return result
+ }
+
+ /// A failed read leaves NO buffer: the next day must go back to the store rather than splice against a
+ /// window nobody successfully filled. Returns empty, which is what the caller saw for a failed read
+ /// before this class existed.
+ private func failedRead() -> [T] {
+ owner = nil
+ // The range is cleared too, not just the owner. Leaving it stale would work only because
+ // `WindowedStreamPlan.plan` happens to test the owner BEFORE the bounds — a cross-file assumption
+ // that holds today and would break silently if those checks were ever reordered. Cheaper to be
+ // self-consistent than to depend on the order of someone else's guards.
+ from = 0
+ to = 0
+ rows = []
+ truncated = false
+ return []
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SseDeltas.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SseDeltas.swift
new file mode 100644
index 0000000000..d50ac9fd5e
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SseDeltas.swift
@@ -0,0 +1,126 @@
+import Foundation
+
+// SseDeltas.swift — pure SSE delta parsing for AI provider streaming.
+//
+// The verifiable core of K1 (streaming): given one SSE `data:` payload, extract the text delta
+// for the provider's wire shape. Pure + deterministic so it is unit-testable without a network,
+// and byte-identical to the Android twin `com.noop.analytics.SseDeltas`. The streaming HTTP glue
+// (URLSession.bytes / OkHttp source) is thin and calls into these pure functions per line.
+//
+// No network, no Foundation URL loading, no I/O — only JSONSerialization (Foundation core, available
+// in the package test target on macOS + Linux). The anonymity / no-raw-egress posture is unchanged:
+// streaming changes transport, not payload.
+
+/// Pure SSE delta extraction for the three provider wire shapes the coach speaks.
+/// Byte-twin of Android `com.noop.analytics.SseDeltas`.
+public enum SseDeltas {
+
+ /// The SSE stream terminator sent by OpenAI-compatible servers.
+ public static let doneMarker = "[DONE]"
+
+ // MARK: - SSE line splitting
+
+ /// Strip the `data: ` prefix from one SSE line and return the raw payload, or nil for blank
+ /// lines (event separators), comments (`: …`), or non-data fields (`event:`, `id:`, etc.).
+ /// Pure; byte-twin of Android `dataPayload(fromLine:)`.
+ public static func dataPayload(fromLine line: String) -> String? {
+ // SSE lines are terminated by \n; the caller splits on \n. A trailing \r (CRLF streams)
+ // must be stripped — Kotlin's trim() removes \r, so .whitespacesAndNewlines keeps parity.
+ // A blank line is an event separator — no payload. A line starting with ":" is a comment.
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return nil }
+ if trimmed.hasPrefix(":") { return nil }
+ // "data:" or "data: " — strip the prefix, then one optional leading space (SSE spec: the
+ // space after the colon is not part of the value).
+ if trimmed.hasPrefix("data:") {
+ var payload = String(trimmed.dropFirst("data:".count))
+ if payload.hasPrefix(" ") { payload = String(payload.dropFirst()) }
+ return payload
+ }
+ // Other SSE fields (event:, id:, retry:) are not data — skip.
+ return nil
+ }
+
+ /// True when a data payload is the `[DONE]` terminator (OpenAI-compatible). Pure.
+ public static func isDone(_ payload: String) -> Bool {
+ payload.trimmingCharacters(in: .whitespacesAndNewlines) == doneMarker
+ }
+
+ // MARK: - Per-provider delta extraction
+
+ /// Extract the text delta from an OpenAI-compatible SSE data payload.
+ /// Shape: `{"choices":[{"delta":{"content":"hello"}}]}` → `"hello"`.
+ /// Returns nil for a `[DONE]` line, a payload with no choices, or a non-content delta (e.g.
+ /// the `role` delta on the first chunk). Pure; byte-twin of Android `openAiDelta`.
+ public static func openAiDelta(_ payload: String) -> String? {
+ if isDone(payload) { return nil }
+ guard let data = payload.data(using: .utf8),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let choices = obj["choices"] as? [[String: Any]],
+ let first = choices.first,
+ let delta = first["delta"] as? [String: Any],
+ let content = delta["content"] as? String else { return nil }
+ // Kotlin twin returns null for empty content (optString.takeIf { isNotEmpty() }), so
+ // onDelta does not fire for OpenAI's usual first delta `"content": ""`. Returning ""
+ // here would fire onDelta on Swift but not Kotlin — breaks K14 haptic parity.
+ return content.isEmpty ? nil : content
+ }
+
+ /// Extract the text delta from a Gemini SSE data payload (`:streamGenerateContent?alt=sse`).
+ /// Shape: `{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}` → `"hello"`.
+ /// Gemini streams full candidate objects per chunk; each may carry multiple parts. Returns nil
+ /// for a chunk with no text parts (e.g. the final `finishReason` chunk). Pure; byte-twin of
+ /// Android `geminiDelta`.
+ public static func geminiDelta(_ payload: String) -> String? {
+ guard let data = payload.data(using: .utf8),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let candidates = obj["candidates"] as? [[String: Any]],
+ let first = candidates.first,
+ let content = first["content"] as? [String: Any],
+ let parts = content["parts"] as? [[String: Any]] else { return nil }
+ let text = parts.compactMap { $0["text"] as? String }.joined()
+ return text.isEmpty ? nil : text
+ }
+
+ /// Extract the text delta from an Anthropic SSE data payload.
+ /// Shape: `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`
+ /// → `"hello"`. Returns nil for non-text-delta events (`message_start`, `ping`, `message_stop`,
+ /// `content_block_start`, etc.). Pure; byte-twin of Android `anthropicDelta`.
+ public static func anthropicDelta(_ payload: String) -> String? {
+ guard let data = payload.data(using: .utf8),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let type = obj["type"] as? String,
+ type == "content_block_delta",
+ let delta = obj["delta"] as? [String: Any],
+ let deltaType = delta["type"] as? String,
+ deltaType == "text_delta",
+ let text = delta["text"] as? String else { return nil }
+ // Kotlin twin returns null for empty text — parity with openAiDelta above.
+ return text.isEmpty ? nil : text
+ }
+
+ // MARK: - Full-stream reassembly (test helper)
+
+ /// Reassemble the full reply text from a complete SSE stream (all lines, in order) for the
+ /// given provider shape. Pure; used by tests to assert the streamed text equals the non-streamed
+ /// reply. Byte-twin of Android `reassemble`.
+ public static func reassemble(lines: [String], provider: SseProvider) -> String {
+ var out = ""
+ for line in lines {
+ guard let payload = dataPayload(fromLine: line) else { continue }
+ let delta: String?
+ switch provider {
+ case .openAi, .custom: delta = openAiDelta(payload)
+ case .gemini: delta = geminiDelta(payload)
+ case .anthropic: delta = anthropicDelta(payload)
+ }
+ if let d = delta { out += d }
+ }
+ return out
+ }
+}
+
+/// The provider wire shape to use for delta extraction. `.custom` shares the OpenAI shape.
+public enum SseProvider {
+ case openAi, custom, gemini, anthropic
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift
index b77c25305b..21603c60b5 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsCounter.swift
@@ -4,33 +4,59 @@ import WhoopProtocol
/// Wrap-aware step derivation from the strap's cumulative `step_motion_counter@57`, shared by the daily
/// total (`AnalyticsEngine.analyzeDay`) and any windowed total (a manual workout's `[start, end]`, #398).
///
-/// `step_motion_counter@57` is a CUMULATIVE u16 running counter: it climbs while you move, holds flat when
-/// still, and wraps at 65536. The number of motion ticks over a set of time-ordered records is the SUM of
-/// WRAP-AWARE increments of that counter — `delta = (cur - prev) & 0xFFFF` — with a per-user
-/// `stepTicksPerStep` calibration applied by the caller AFTERWARDS (this returns the raw pre-calibration
-/// tick total, so the two callers can never disagree on the counter math). The raw total is an ESTIMATE
-/// (@57 counts motion ticks, not validated steps), not cloud/clinical parity.
+/// `step_motion_counter@57` is a CUMULATIVE u16 motion counter: it climbs for both locomotion and some
+/// non-step wrist motion, and wraps at 65536. On classed WHOOP 5/MG records, the increment ending at each
+/// sample is counted only when the strap labels that sample walk (1) or run (2); still (0) and unknown are
+/// rejected. A wholly unclassed legacy window retains the old counter-only estimate so pre-migration history
+/// remains readable. The caller applies its per-user `stepTicksPerStep` calibration afterwards. The result is
+/// still an estimate, not cloud/clinical parity.
///
/// Kept byte-for-byte in lockstep with the Kotlin twin `StepsCounter.stepsInWindow`.
public enum StepsCounter {
- /// The largest wrap-aware increment treated as real motion between two adjacent 1 Hz records. A delta
- /// at/above this is a big time-gap / disconnect boundary between sync sessions (or a firmware reboot,
- /// byte-indistinguishable from a u16 wrap), NOT real steps — dropped so gaps don't inflate the total.
- /// Real 1 Hz motion never ticks this fast between adjacent records. (#132/#276/#316)
+ private static let locomotionActivityClasses: Set = [1, 2]
+
+ static func hasActivityClasses(_ samples: [StepSample]) -> Bool {
+ samples.contains { $0.activityClass != nil }
+ }
+
+ /// Kotlin twin: `StepsCounter.shouldCountDelta`.
+ static func shouldCountDelta(activityClass: Int?, hasActivityClasses: Bool) -> Bool {
+ !hasActivityClasses || activityClass.map(locomotionActivityClasses.contains) == true
+ }
+
+ /// Absolute reboot/wrap guard, independent from the per-second plausibility gate below.
public static let maxStepDelta = 512
+ public static let maxTicksPerSecond = 4
+
+ /// Kotlin twin: `StepsCounter.isPlausibleDelta`.
+ static func isPlausibleDelta(previousTs: Int, currentTs: Int, delta: Int) -> Bool {
+ guard delta >= 1, delta < maxStepDelta else { return false }
+ let elapsed = currentTs - previousTs
+ guard elapsed > 0 else { return false }
+ let rateAllowance = elapsed >= maxStepDelta / maxTicksPerSecond
+ ? maxStepDelta - 1
+ : elapsed * maxTicksPerSecond
+ return delta <= rateAllowance
+ }
- /// Raw wrap-aware motion-tick total across `samples` — the sum of positive consecutive
- /// `step_motion_counter@57` increments in `[1, maxStepDelta)`. Sorts by `ts` internally, so the caller
- /// may pass an unsorted window (already filtered to the range it cares about). Returns `nil` when there
- /// are fewer than two samples or no forward movement (so "no data" stays distinct from a real zero).
- /// The caller applies its `stepTicksPerStep` calibration to the returned ticks.
+ /// Raw wrap-aware locomotion-tick total across `samples`. When any sample carries `activityClass`, each
+ /// positive increment is attributed to the later sample and retained only for walk/run. When the whole
+ /// window is legacy-unclassed, all valid increments retain the historical counter-only fallback. Sorts
+ /// by `ts` internally and returns `nil` for fewer than two samples or no retained movement.
public static func stepsInWindow(_ samples: [StepSample]) -> Int? {
let sorted = samples.sorted { $0.ts < $1.ts }
if sorted.count < 2 { return nil }
+ let hasActivityClasses = hasActivityClasses(sorted)
var total = 0
for i in 1..= 1 && delta < maxStepDelta { total += delta } // ignore a delta >= 512 (gap/reset)
+ let isLocomotion = shouldCountDelta(
+ activityClass: sorted[i].activityClass,
+ hasActivityClasses: hasActivityClasses)
+ if isLocomotion && isPlausibleDelta(
+ previousTs: sorted[i - 1].ts, currentTs: sorted[i].ts, delta: delta) {
+ total += delta
+ }
}
return total > 0 ? total : nil
}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift
index fc75a24fd1..1151d99de5 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift
@@ -212,6 +212,7 @@ public enum StrainScorer {
public static func effectiveEffort(live: Double?, stored: Double?) -> Double? {
guard let live else { return stored }
guard let stored else { return live }
+ if live == 0.0 && stored == 0.0 { return 0.0 }
return Swift.max(live, stored)
}
@@ -346,7 +347,15 @@ public enum StrainScorer {
restingHR: Double = defaultRestingHR,
method: Method = .edwards,
sex: String = "male",
- denominator: Double? = nil) -> Double? {
+ denominator: Double? = nil,
+ // Optional diagnostic sink. Nil by default and the line is built ONLY when
+ // one is supplied, so a pass nobody is watching pays nothing.
+ //
+ // Supplying one BYPASSES the memo — see the guard below. Without that the
+ // line would usually never appear, because the Today view has already cached
+ // the day at live-HR tick rate before the scoring pass asks.
+ diag: ((String) -> Void)? = nil,
+ day: String = "") -> Double? {
// Resolve BEFORE the memo key is built, or a Banister request would be cached under Edwards'
// denominator and a later Edwards request could collide with it.
let resolvedDenominator = denominator ?? logMapDenominator(method: method, sex: sex)
@@ -358,12 +367,53 @@ public enum StrainScorer {
hr: StreamFingerprint.of(hr, ts: { $0.ts }, quant: { Int($0.bpm) }),
maxHR: maxHR, restingHR: restingHR, method: method,
sexF: sex.lowercased().hasPrefix("f"), denom: resolvedDenominator)
+ // A diagnostic request BYPASSES the memo, and must. The Today view re-reads this on every
+ // live-HR tick with no sink, so by the time the scoring pass asks with one the answer is already
+ // cached — and a cache hit never reaches the code that emits, so the line would simply never
+ // appear. Recomputing one day per pass to be able to report it is the trade, and it is a cheap
+ // one. Kotlin has no memo here and always emits; this keeps the two behaving the same.
+ guard diag == nil else {
+ return strainUncached(hr, maxHR: maxHR, restingHR: restingHR, method: method, sex: sex,
+ denominator: resolvedDenominator, diag: diag, day: day)
+ }
return strainCache.value(key) {
strainUncached(hr, maxHR: maxHR, restingHR: restingHR, method: method, sex: sex,
- denominator: resolvedDenominator)
+ denominator: resolvedDenominator, diag: nil, day: day)
}
}
+ /// One line naming what an Effort score was computed FROM, or why it could not be computed.
+ ///
+ /// The gap this closes: `strain` is the only score in the app with no trace at all. WorkoutDetector,
+ /// SleepStager and both engines each emit a funnel; the number on the Today hero ring emitted nothing,
+ /// so a log could not distinguish "measured, and the day was genuinely calm" from "could not measure".
+ /// A reader looking for the latter finds `workout detect`, which is WORKOUT-BOUT detection and answers
+ /// a different question — a confusion that has already produced one wrong diagnosis.
+ ///
+ /// `enough` is the `strain` gate spelled out: dense (>= minReadings) OR sparse-but-sustained. `trimp`
+ /// and `strain` are absent when the gate refused, which is exactly the case a bare 0 hides.
+ /// Byte-identical to the Kotlin `StrainScorer.scoreFunnelLine`.
+ public static func scoreFunnelLine(day: String, hrSamples: Int, enough: Bool,
+ maxHR: Double, maxHRProvided: Bool, restingHR: Double,
+ method: Method, trimp: Double?, strain: Double?) -> String {
+ func r1(_ v: Double) -> String { String(format: "%.1f", v) }
+ // Built by appending rather than as one `+` chain. A chain of interpolated segments is a single
+ // expression, and this one blew the type-checker's budget on the first attempt — the same failure
+ // that took the iOS build down in #1767. Statements give the solver one small problem at a time.
+ let src: String = maxHRProvided ? "provided" : "default"
+ let trimpText: String = trimp.map(r1) ?? "n/a"
+ let strainText: String = strain.map(r1) ?? "n/a"
+ var out = "effort score day=\(day) hr=\(hrSamples) enough=\(enough)"
+ out += " hrMax=\(r1(maxHR))(\(src))"
+ out += " rhr=\(r1(restingHR)) reserve=\(r1(maxHR - restingHR))"
+ // Method carries no raw value here; interpolating the case yields "edwards"/"banister", which
+ // is exactly what Kotlin's `method.name.lowercase()` produces. The two lines must match byte for
+ // byte, so this is the one spelling that keeps them equal.
+ out += " method=\(method)"
+ out += " trimp=\(trimpText) strain=\(strainText)"
+ return out
+ }
+
/// Key folds `sex` to the single bit the recipe reads (`hasPrefix("f")`) so "female"/"f"/"F" all hit.
private struct StrainKey: Hashable {
let hr: StreamFingerprint
@@ -373,7 +423,8 @@ public enum StrainScorer {
private static let strainCache = AnalyticsMemoCache(capacity: 48)
private static func strainUncached(_ hr: [HRSample], maxHR: Double?, restingHR: Double,
- method: Method, sex: String, denominator: Double) -> Double? {
+ method: Method, sex: String, denominator: Double,
+ diag: ((String) -> Void)? = nil, day: String = "") -> Double? {
let effMax = maxHR ?? Double(defaultMaxHR())
// Enough data to trust the score: a dense stream (≥ minReadings) OR a sparse-but-sustained
// one spanning ≥ minSpanSeconds with a sample floor (#482 — the 5/MG's ~30 s HR cadence).
@@ -386,7 +437,14 @@ public enum StrainScorer {
} else {
enoughData = false
}
- if !enoughData || effMax <= restingHR { return nil }
+ if !enoughData || effMax <= restingHR {
+ // The refusal is the half a bare number cannot show: nil here and 0.0 on a calm day look
+ // identical on the ring, and only one of them is a measurement.
+ diag?(scoreFunnelLine(day: day, hrSamples: hr.count, enough: enoughData, maxHR: effMax,
+ maxHRProvided: maxHR != nil, restingHR: restingHR, method: method,
+ trimp: nil, strain: nil))
+ return nil
+ }
let durations = sampleDurationsMinutes(hr)
let hrReserve = effMax - restingHR
@@ -404,6 +462,10 @@ public enum StrainScorer {
trimp = edwardsTRIMP(hr, restingHR: restingHR, hrReserve: hrReserve,
durations: durations)
}
- return trimpToStrain(trimp, denominator: denominator)
+ let scored = trimpToStrain(trimp, denominator: denominator)
+ diag?(scoreFunnelLine(day: day, hrSamples: hr.count, enough: enoughData, maxHR: effMax,
+ maxHRProvided: maxHR != nil, restingHR: restingHR, method: method,
+ trimp: trimp, strain: scored))
+ return scored
}
}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StreamReadCap.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StreamReadCap.swift
new file mode 100644
index 0000000000..208508ffe0
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StreamReadCap.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+/// Per-stream read caps for the `analyzeRecent` sliding windows (#1538).
+///
+/// One shared cap of 200,000 covered both heavy streams, and it was sized for HR. A field capture
+/// (2026-09-05, WHOOP 5/MG, 21 nights) shows what that cost:
+///
+/// hr[read=1723815 served=1826919 truncated=0]
+/// rr[read=2563444 served=610905 truncated=10]
+///
+/// Ten R-R windows came back AT the cap. `SlidingStreamWindow.truncatedReads` is explicit about what
+/// that means - "a non-zero count means a number may be wrong, not merely slow" - because the read is
+/// `ORDER BY ts ASC LIMIT`, so what gets dropped is the NEWEST rows. Every HRV number derived from one
+/// of those windows was computed on a night missing its tail, silently.
+///
+/// ## Why R-R and not HR
+///
+/// Measured, not reasoned from beat rate - the capture contradicts the obvious argument. R-R is one row
+/// per BEAT, so it "should" outrun HR's 1/s, yet a stride read in that same log returned 81,009 R-R rows
+/// against 107,415 HR: R-R is recorded in BURSTS, not continuously, so its average over a window sits
+/// BELOW HR's while its peaks run far above. Averages are why one cap looked safe; peaks are what
+/// truncated it.
+///
+/// What is certain is the behaviour: across 21 nights R-R came back at the 200,000 cap ten times and HR
+/// never did. HR's own margin is the other half of the story - a full 54-hour window at 1/s is ~194,400
+/// rows, 3% under the cap, so HR was never comfortable either, merely lucky.
+///
+/// The caps are therefore sized against what a window can PEAK at, not what it averages. The rate
+/// constants below are deliberately generous for that reason: they are a ceiling to stay clear of, not a
+/// prediction of typical density.
+///
+/// So the bug was not the number being too small. It was ONE number serving two streams whose peak
+/// densities differ, where a value comfortable for one is a silent truncation for the other.
+///
+/// ## The invariant
+///
+/// A cap must exceed what a full window can legitimately hold, or a complete read is indistinguishable
+/// from a truncated one. `capExceedsFullWindow` pins exactly that, per stream, so a future change to the
+/// window span or a stream's rate fails a test rather than quietly clipping a night.
+///
+/// Note the cap is a ceiling, not an allocation: a read still returns only the rows that exist. Raising
+/// it costs nothing on a normal night and buys correctness on a dense one.
+public enum StreamReadCap {
+
+ /// How far BEFORE the day boundary the pass reads. EVERY lookback in the engine takes this value -
+ /// the per-day `from`, the window start, the skin-anchor scan - so the window and the caps derived
+ /// from it cannot drift apart - widening the lookback there without
+ /// resizing the caps here is precisely how a stream starts truncating again, and is the "one number
+ /// in two places" shape this whole type exists because of.
+ public static let lookbackSeconds = 30 * 3_600
+
+ /// How far AFTER it, capped at `now` for a day still in progress. A whole day.
+ public static let forwardSeconds = 24 * 3_600
+
+ /// The per-day read window: `dayStart - 30h` through the night, i.e. 54 hours.
+ public static let windowSeconds = lookbackSeconds + forwardSeconds
+
+ /// HR: one sample per second.
+ public static let hrRowsPerSecond = 1.0
+
+ /// R-R at its PEAK. The measured average is below 1/s (it arrives in bursts, with gaps), but bursts
+ /// are what reach a cap - and #1008's cross-second overcount can double the rows a burst produces.
+ /// 2/s is a ceiling chosen to stay clear of, not a claim about typical density.
+ public static let rrRowsPerSecond = 2.0
+
+ /// Gravity: one sample per second, like HR, and read over the SAME 54-hour window - the code's claim
+ /// that "the other eight streams are thousands of rows" does not hold for this one. A field capture
+ /// measured 192,698 gravity rows, 96% of the old shared cap.
+ ///
+ /// It is the most dangerous of the three, because it is a PLAIN read rather than a
+ /// `SlidingStreamWindow`: nothing counts a truncation here, so a clip would be silent even by the
+ /// standard that caught R-R. Sleep STAGING is what consumes it, so the cost of a clipped read is a
+ /// mis-staged night rather than a slow one.
+ public static let gravityRowsPerSecond = 1.0
+
+ /// Headroom over a full window, so a legitimate read cannot land ON the cap and be mistaken for a
+ /// truncated one.
+ public static let headroom = 1.5
+
+ /// The cap on the sleep engine's skin-temp reads, named rather than left a literal so the no-night
+ /// line can say when one came back AT it. Skin reads OUTSIDE the engine (a chart, an export) keep
+ /// their own limits - this is not a repo-wide skin cap.
+ ///
+ /// Not derived from a row rate like the caps below: skin's density was never measured. Skin rides the
+ /// record that carries gravity, and gravity ranged from 177 to 192,698 rows across 54 HOURS in one
+ /// capture, so whether 200,000 is generous or tight is genuinely unknown - which is the reason to
+ /// print the count. Unchanged in value: this names what both platforms already read at.
+ ///
+ /// Shared by two reads with very different spans - the night window, and the ~21-day WHOOP 4.0 anchor
+ /// scan. Only the window read's count reaches the no-night line, so an `atCap=skin` marker means THAT
+ /// read was clipped; a clipped anchor scan still goes unreported.
+ public static let skin = 200_000
+
+ /// The cap for a stream producing `rowsPerSecond` at its densest.
+ public static func cap(rowsPerSecond: Double) -> Int {
+ Int((Double(windowSeconds) * rowsPerSecond * headroom).rounded(.up))
+ }
+
+ /// 291,600 - a full HR window plus half again.
+ ///
+ /// STORED, not computed. A window's read cap and its truncation threshold must be the same number,
+ /// and the engine names this in both slots - a stored constant makes those two references provably
+ /// one value rather than two evaluations that merely ought to agree.
+ public static let hr = cap(rowsPerSecond: hrRowsPerSecond)
+
+ /// 583,200 - a full R-R window at its densest, plus half again. Stored for the reason `hr` gives.
+ public static let rr = cap(rowsPerSecond: rrRowsPerSecond)
+
+ /// 291,600 - the same shape as `hr`, for the same 54-hour window. Only the 54-hour read needs it;
+ /// the day-scoped gravity reads span 24 hours and cannot approach any of these.
+ public static let gravity = cap(rowsPerSecond: gravityRowsPerSecond)
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift
index d8703124b0..26c8bcd0c1 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WakeMotionRefinement.swift
@@ -135,8 +135,14 @@ public enum WakeMotionRefinement {
let newStages = refine(session.stages, grav: grav, steps: steps)
guard newStages != session.stages else { return session }
let newEfficiency = SleepStager.efficiency(start: session.start, end: session.end, stages: newStages)
+ // `hrOnly` carried explicitly. The Kotlin twin is a `copy`, which preserves a new field for
+ // free; this rebuilds the struct field by field and drops one BY DEFAULT. Refinement only ever
+ // sees motion-detected sessions today, so an HR-only night never reaches here — but the two
+ // platforms would have disagreed silently the moment it did, which is the shape of divergence
+ // the parity contract exists to catch.
return SleepSession(start: session.start, end: session.end, efficiency: newEfficiency,
- stages: newStages, restingHR: session.restingHR, avgHRV: session.avgHRV)
+ stages: newStages, restingHR: session.restingHR, avgHRV: session.avgHRV,
+ hrOnly: session.hrOnly)
}
/// Toggle-shaped convenience for the session-level overload (see `apply(_:grav:steps:enabled:)`).
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift
new file mode 100644
index 0000000000..05d0425bee
--- /dev/null
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift
@@ -0,0 +1,94 @@
+import Foundation
+
+/// How to satisfy one day's windowed stream read given what the previous day already read (#1538).
+///
+/// `analyzeRecent`'s pass-1 loop walks BACKWARDS — `dayStart = nowLocalMidnight - offset * 24h` — and reads
+/// a 54-hour window around each night (`dayStart - 30h` .. next local midnight). On a 24-hour stride that
+/// makes consecutive windows overlap by 30 hours, so every row is materialised about 2.25 times per pass.
+/// The `analyzeRecent cost prep=…ms score=…ms` line exists to decide whether that is worth narrowing, and a
+/// field log answered it: prep 48129ms against score 31516ms on a cold 21-day pass, so the reads are the
+/// larger half and the overlap is worth removing.
+///
+/// This is the DECISION half, kept pure so both platforms can be pinned against one oracle. Splicing the
+/// rows is mechanical and stays with the caller. Twin of Kotlin `WindowedStreamPlan`.
+///
+/// The planner REFUSES rather than guesses. Every case it cannot prove safe returns `.fullRead`, which is
+/// byte-for-byte the behaviour that shipped before this existed — the same stance `daySliceFromNight` takes
+/// when it declines. An optimisation on the scoring path may only ever skip work, never change a number.
+public enum WindowedStreamPlan {
+
+ /// What the sliding windows saved, for the line emitted beside `analyzeRecent`'s `prep`/`score`
+ /// split — the measurement the decision to build them was made from.
+ ///
+ /// `read` is rows fetched from the store, `served` rows a buffer supplied instead. A pass where
+ /// `served` is ~0 means the windows are declining — truncation, an owner flip, or a gap left by a
+ /// dayCache hit — and the reads are back to exactly what they were. That is the honest outcome rather
+ /// than a silent one, which is why the counters ship rather than just the speedup.
+ ///
+ /// `truncated` separates the one of those causes that is also a BUG. The reads are
+ /// `ORDER BY ts ASC LIMIT`, so a read at the cap drops the NEWEST rows and the day is scored on a
+ /// window missing its tail, with nothing else in the log to say so. A field log showed a strap at
+ /// 192,680 rows against the 200,000 cap — 96% — with `served` near zero on one stream and half on the
+ /// other, and no way to tell which cause fired. Hence this counter rather than a guess.
+ ///
+ /// Lives HERE rather than on the engine so both platforms put it in the same place and both are
+ /// covered by default CI — the Swift engine is app-target and has none. Byte-identical to the Kotlin
+ /// `WindowedStreamPlan.logLine`.
+ public static func logLine(
+ hrRead: Int,
+ hrServed: Int,
+ hrTruncated: Int,
+ rrRead: Int,
+ rrServed: Int,
+ rrTruncated: Int
+ ) -> String {
+ "analyzeRecent windows hr[read=\(hrRead) served=\(hrServed) truncated=\(hrTruncated)] "
+ + "rr[read=\(rrRead) served=\(rrServed) truncated=\(rrTruncated)]"
+ }
+
+ /// What the caller should do to obtain rows for the requested window.
+ public enum Plan: Equatable {
+ /// Read the whole window from the store; nothing usable is buffered.
+ case fullRead
+ /// The buffer already covers the window; slice it, read nothing.
+ case serve
+ /// Read only `[readFrom, readTo]` (inclusive, both ends) and prepend it to the buffer. `readTo` is
+ /// one second BELOW the buffer's current start, so the boundary row is never read twice — the
+ /// store's range predicate is inclusive at both ends.
+ case extend(readFrom: Int, readTo: Int)
+ }
+
+ /// Plan the read for `[from, to]` against a buffer holding `[cachedFrom, cachedTo]` for `cachedOwner`.
+ ///
+ /// `cachedOwner` nil means nothing is buffered. `cachedTruncated` means the buffered read hit the
+ /// store's row limit, so its contents do not represent its range — that buffer can never be sliced, for
+ /// the reason `daySliceFromNight` gives.
+ ///
+ /// After the caller acts on the plan the buffer covers exactly `[from, to]`: rows above `to` are
+ /// dropped, which is what keeps the peak footprint at one window rather than the whole pass. A backward
+ /// walk never asks for them again.
+ public static func plan(cachedOwner: String?,
+ cachedFrom: Int,
+ cachedTo: Int,
+ cachedTruncated: Bool,
+ owner: String,
+ from: Int,
+ to: Int) -> Plan {
+ // Nothing buffered, a different strap owns this day, or the buffered read was cut off at the row
+ // limit — none of these can be sliced, and a wrong slice is a wrong score.
+ guard let cachedOwner, cachedOwner == owner, !cachedTruncated else { return .fullRead }
+ // A degenerate or inverted buffer holds nothing meaningful.
+ if cachedFrom > cachedTo { return .fullRead }
+ // An inverted request is not this planner's business to repair.
+ if from > to { return .fullRead }
+ // The buffer covers the request outright.
+ if from >= cachedFrom && to <= cachedTo { return .serve }
+ // The ONLY extension this walk produces: the window moved earlier, and its tail is still inside
+ // what is buffered. Anything else (a window that moved forward, or one disjoint from the buffer)
+ // is not the backward stride this exists for, so it reads in full rather than being reasoned about.
+ if from < cachedFrom && to >= cachedFrom && to <= cachedTo {
+ return .extend(readFrom: from, readTo: cachedFrom - 1)
+ }
+ return .fullRead
+ }
+}
diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift
index f0266894ef..1029bb5a05 100644
--- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift
+++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift
@@ -203,7 +203,7 @@ public enum WorkoutDetector {
/// Byte-identical string to the Kotlin twin. No PII: a day key and counts, plus the two bpm thresholds
/// the day was measured against — the same privacy class as the sibling `sleep day=` line.
public static func detectionFunnelLine(day: String, funnel f: DetectionFunnel) -> String {
- "effort detect day=\(day) hr=\(f.hrSamples) motion=\(f.motionSamples) "
+ "workout detect day=\(day) hr=\(f.hrSamples) motion=\(f.motionSamples) "
+ "restHR=\(round0(f.restingHR)) floor=\(round0(f.hrFloor)) "
+ "motionOK=\(f.motionPassed) hrMissing=\(f.hrMissing) hrTooLow=\(f.hrTooLow) "
+ "active=\(f.active) runs=\(f.runs) bridged=\(f.bridged) "
@@ -578,6 +578,17 @@ public enum WorkoutDetector {
/// APPROXIMATE — not laboratory calorimetry, not medical advice.
public enum Calories {
+ /// Whole-day energy split. `restingKcal` is the BMR contribution over supported sample intervals;
+ /// `activeKcal` is energy above that resting floor during supported high-HR intervals.
+ /// `totalKcal` is the backward-compatible value persisted in `DailyMetric.activeKcalEst`.
+ public struct DayEnergyEstimate: Equatable, Sendable {
+ public let restingKcal: Double
+ public let activeKcal: Double
+ public let observedSeconds: Double
+
+ public var totalKcal: Double { restingKcal + activeKcal }
+ }
+
struct Coeffs {
let restingAlpha: Double
let restingWeight: Double
@@ -625,6 +636,14 @@ public enum Calories {
/// Keytel is appropriate for a real detected/manual workout — but the day path raises
/// the gate to 50% HRR so the gross rate only applies at genuine exercise-level HR.
static let dayActiveHRRFraction = 0.50
+ /// Longest gap over which one daily HR reading may carry RESTING energy.
+ ///
+ /// Resting metabolism continues across a dropout, so a reading may carry the BMR rate into the gap
+ /// — but only so far, or a disconnected evening would bank a full night of resting kcal that was
+ /// never observed. ACTIVE energy is capped separately and much tighter, at the inferred cadence
+ /// (see `estimateDayEnergy`): a gap is evidence of a missing sensor, never of exercise.
+ static let dayMaxObservedGapS: Double = 60.0
+ static let dayMaxObservedSpanS: Double = 86_400.0
static let workoutDivisor = 251.04 // 60 s/min × 4.184 kJ/kcal
static func resolveCoeffs(_ sex: String) -> Coeffs {
@@ -725,10 +744,9 @@ public enum Calories {
return (totalKcal, totalKcal * 4.184)
}
- /// APPROXIMATE whole-day total energy estimate (kcal) from the full day's HR samples.
- /// Per-second model: below the day activeThreshold (resting + `dayActiveHRRFraction`
- /// HRR) a sample burns the resting BMR rate, above it the Keytel active rate — FLOORED
- /// at the resting rate so a day-second can never be credited LESS than resting metabolism.
+ /// APPROXIMATE whole-day resting + active energy estimate from the full day's HR samples.
+ /// Resting BMR is integrated once over capped, supported sample intervals, independent of HR cadence.
+ /// Supported high-HR intervals then add only the Keytel energy ABOVE that resting floor.
///
/// The day path uses `dayActiveHRRFraction` (50% HRR), NOT the 30% the bout detector uses
/// (`activeHRRFraction`). The Keytel 2005 equation is validated for genuine EXERCISE HR;
@@ -738,19 +756,21 @@ public enum Calories {
/// the gross rate for genuine exercise-level HR only; the bout path is UNCHANGED — Keytel
/// is appropriate there, on a real detected/manual workout.
///
- /// Each HR sample = ONE second of data (1 Hz strap), counted flat — this path deliberately
- /// does NOT use the bout estimator's elapsed-time-per-sample weighting. The day feed is a
- /// raw, non-gap-filled union of the day's HR (it is NOT motion-gated the way a bout is), so
- /// capping each gap at mergeGapS (150 s) would credit up to ~150 s of active burn to a
- /// single isolated elevated sample — over-counting by ~150x on gappy days. Flat
- /// one-second-per-sample is the conservative, stable choice for the day total.
+ /// Cadence is inferred from the median positive timestamp gap and capped at
+ /// `dayMaxObservedGapS`. Each sample carries resting energy, and a high-HR sample carries active
+ /// energy, to the next sample for at most that cap. This can credit up to 60 s instead of the old
+ /// flat 1 s for a reading on a gappy day, but never a whole disconnect. Thus a 30 s sparse stream
+ /// and a 1 Hz stream covering the same activity produce comparable energy.
+ ///
/// This is an on-device estimate from heart rate alone — NOT laboratory calorimetry, NOT
- /// Apple/WHOOP cloud parity, NOT medical advice. Returns total estimated kcal (>= 0).
- public static func estimateDayCalories(_ hrSamples: [HRSample],
- profile: UserProfile,
- hrmax: Double?,
- restingHR: Double?) -> Double {
- if hrSamples.isEmpty { return 0.0 }
+ /// Apple/WHOOP cloud parity, NOT medical advice.
+ public static func estimateDayEnergy(_ hrSamples: [HRSample],
+ profile: UserProfile,
+ hrmax: Double?,
+ restingHR: Double?) -> DayEnergyEstimate {
+ if hrSamples.isEmpty {
+ return DayEnergyEstimate(restingKcal: 0, activeKcal: 0, observedSeconds: 0)
+ }
let weightKg = profile.weightKg > 0 ? profile.weightKg : 70.0
let heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0
@@ -768,19 +788,61 @@ public enum Calories {
// restingHR → base model, unchanged. Constant across the day.
let vo2max = vo2maxFor(hrmax: effHRmax, restingHR: restingHR)
- var totalKcal = 0.0
- for s in hrSamples {
- let bpm = Double(s.bpm)
- if bpm < activeThreshold {
- totalKcal += restingRate
+ // Ties are REACHABLE: hrSample is keyed (deviceId, ts) and the day feed unions devices, so a
+ // two-strap day carries two readings for the same second. Only the LAST of a tied run gets the
+ // interval (the earlier ones measure a zero gap), so tie order decides the day's active energy.
+ // Ordering ties by DESCENDING bpm hands the interval to the LOWER reading — the conservative
+ // direction for a path whose history is over-counting. Sorting on ts alone left this to the
+ // sort's stability, which Kotlin guarantees (TimSort) and Swift explicitly does not.
+ let ordered = hrSamples.sorted { $0.ts != $1.ts ? $0.ts < $1.ts : $0.bpm > $1.bpm }
+ let positiveGaps = zip(ordered, ordered.dropFirst())
+ .map { pair in Double(pair.1.ts - pair.0.ts) }
+ .filter { $0 > 0 }
+ .sorted()
+ let nominalSampleS: Double = {
+ guard !positiveGaps.isEmpty else { return 1.0 }
+ let mid = positiveGaps.count / 2
+ let median = positiveGaps.count.isMultiple(of: 2)
+ ? (positiveGaps[mid - 1] + positiveGaps[mid]) / 2.0
+ : positiveGaps[mid]
+ return min(median, dayMaxObservedGapS)
+ }()
+ let observedSeconds = min(dayMaxObservedSpanS, positiveGaps.reduce(nominalSampleS) {
+ $0 + min($1, dayMaxObservedGapS)
+ })
+ let restingKcal = restingRate * observedSeconds
+
+ var activeKcal = 0.0
+ for i in ordered.indices {
+ let durationS: Double
+ // Active carry is capped at the INFERRED CADENCE, not at the wider resting cap. A 30 s
+ // stream still carries its full 30 s (the cadence bug this fixes), and a 1 Hz day carries
+ // 1 s so the legacy total is reproduced exactly — but a dropout in an otherwise dense day
+ // no longer credits a full minute of exercise to the last reading before it. That was the
+ // objection the flat one-second model was written to avoid, and capping active at the
+ // cadence answers it rather than narrowing it from 150 s to 60 s.
+ if i < ordered.count - 1 {
+ let gap = Double(ordered[i + 1].ts - ordered[i].ts)
+ durationS = gap > 0 ? min(gap, nominalSampleS) : 0.0
} else {
- // Floor the active rate at the resting BMR rate: a worn day-second never burns
- // LESS than resting metabolism, even where the Keytel value dips low for some
- // profiles just above the gate.
- let active = activeKcalPerS(coeffs, hr: bpm, hrmax: effHRmax, weightKg: weightKg, age: age, vo2max: vo2max)
- totalKcal += max(restingRate, active)
+ durationS = nominalSampleS
}
+ let bpm = Double(ordered[i].bpm)
+ guard bpm >= activeThreshold else { continue }
+ let grossRate = activeKcalPerS(coeffs, hr: bpm, hrmax: effHRmax,
+ weightKg: weightKg, age: age, vo2max: vo2max)
+ activeKcal += max(0.0, grossRate - restingRate) * durationS
}
- return totalKcal
+ return DayEnergyEstimate(restingKcal: restingKcal, activeKcal: activeKcal,
+ observedSeconds: observedSeconds)
+ }
+
+ /// Backward-compatible total-kcal facade for the stored daily metric.
+ public static func estimateDayCalories(_ hrSamples: [HRSample],
+ profile: UserProfile,
+ hrmax: Double?,
+ restingHR: Double?) -> Double {
+ estimateDayEnergy(hrSamples, profile: profile, hrmax: hrmax,
+ restingHR: restingHR).totalKcal
}
}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AdaptiveExpenditureEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AdaptiveExpenditureEngineTests.swift
new file mode 100644
index 0000000000..98d483807b
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AdaptiveExpenditureEngineTests.swift
@@ -0,0 +1,127 @@
+import Foundation
+import XCTest
+@testable import StrandAnalytics
+
+/// TDEE by energy balance — the twin of the Kotlin `AdaptiveExpenditureEngineTest`, asserting the same
+/// cases with the same inputs so the two cannot drift.
+///
+/// The identity is `expenditure = intake - stored-energy change`. Most of what matters here is refusing
+/// to answer: the method is meaningful only over weeks, and most installs will never log enough intake.
+final class AdaptiveExpenditureEngineTests: XCTestCase {
+
+ /// A synthetic history: `days` long, intake every `logEvery` days, weight every `weighEvery`.
+ private func history(days: Int, intake: Double = 2_400, startKg: Double = 80,
+ kgPerDay: Double = 0, logEvery: Int = 1,
+ weighEvery: Int = 3) -> [AdaptiveExpenditureDay] {
+ (0.. String {
+ var c = DateComponents(); c.year = 2026; c.month = 1; c.day = 1 + offset
+ var cal = Calendar(identifier: .gregorian)
+ cal.timeZone = TimeZone(identifier: "UTC")!
+ let d = cal.date(from: c)!
+ let f = DateFormatter()
+ f.calendar = cal; f.timeZone = cal.timeZone
+ f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = "yyyy-MM-dd"
+ return f.string(from: d)
+ }
+
+ /// Weight flat ⇒ expenditure equals intake. The simplest reading of the identity.
+ func testStableWeightPutsExpenditureAtIntake() throws {
+ let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30)))
+ XCTAssertEqual(est.estimatedDailyKcal, 2_400, accuracy: 1)
+ XCTAssertEqual(est.weightSlopeKgPerDay, 0, accuracy: 1e-9)
+ }
+
+ /// Losing weight ⇒ expenditure EXCEEDED intake, so the estimate is ABOVE what was eaten. The sign is
+ /// the classic error in this formula, so both directions are pinned rather than one.
+ func testLosingWeightPutsExpenditureAboveIntake() throws {
+ let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30, kgPerDay: -0.05)))
+ // 0.05 kg/day * 7700 = 385 kcal/day of stored energy released.
+ XCTAssertEqual(est.estimatedDailyKcal, 2_400 + 385, accuracy: 2)
+ XCTAssertGreaterThan(est.estimatedDailyKcal, est.meanIntakeKcal)
+ }
+
+ /// And the mirror: gaining weight means expenditure fell SHORT of intake.
+ func testGainingWeightPutsExpenditureBelowIntake() throws {
+ let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30, kgPerDay: 0.05)))
+ XCTAssertEqual(est.estimatedDailyKcal, 2_400 - 385, accuracy: 2)
+ XCTAssertLessThan(est.estimatedDailyKcal, est.meanIntakeKcal)
+ }
+
+ /// Under three weeks the method is not meaningful, so it declines rather than answering.
+ func testShortHistoryYieldsNothing() {
+ XCTAssertNil(AdaptiveExpenditureEngine.estimate(days: history(days: 20)))
+ }
+
+ /// The gate that matters most in practice: someone logs hard for a fortnight of a long window and
+ /// stops. The skipped days are not a random sample of their eating, so a mean over them would read
+ /// as a large false deficit.
+ func testSparseIntakeLoggingYieldsNothing() {
+ XCTAssertNil(AdaptiveExpenditureEngine.estimate(days: history(days: 40, logEvery: 3)))
+ }
+
+ /// Weight is the other half of the identity; too few readings cannot establish a trend.
+ func testTooFewWeighInsYieldsNothing() {
+ XCTAssertNil(AdaptiveExpenditureEngine.estimate(days: history(days: 30, weighEvery: 10)))
+ }
+
+ /// The output is a RANGE, and the estimate must sit inside it.
+ func testEstimateIsBracketedByItsInterval() throws {
+ let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30)))
+ XCTAssertLessThan(est.lowerKcal, est.estimatedDailyKcal)
+ XCTAssertGreaterThan(est.upperKcal, est.estimatedDailyKcal)
+ }
+
+ /// Confidence rises with the window, coverage and weigh-ins — never with the value itself.
+ func testConfidenceReflectsCoverageNotTheAnswer() throws {
+ let full = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 35, weighEvery: 2)))
+ XCTAssertEqual(full.confidence, .high)
+ let thin = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 22, weighEvery: 3)))
+ XCTAssertNotEqual(thin.confidence, .high)
+ }
+
+ /// The window must hold exactly the days it claims. `dayCount` is inclusive, so a strict `<` in the
+ /// recency filter drops the oldest day while still reporting the full window — losing a day of data
+ /// and understating coverage, both silently. Every other case here logs every day, so a one-day loss
+ /// crosses no gate and nothing else would notice.
+ func testFullyLoggedWindowKeepsEveryDayItReports() throws {
+ let est = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: history(days: 30)))
+ XCTAssertEqual(est.windowDays, 30)
+ XCTAssertEqual(est.intakeDays, est.windowDays)
+ }
+
+ /// A duplicated day must not buy confidence. Coverage is "share of the window that was logged", so
+ /// it cannot exceed 1 — but a caller that merged its two sparse series badly could pass a day twice,
+ /// and an unclamped ratio above 1 both shrinks the interval and lifts the confidence, making the
+ /// answer look more certain than its data. That is the one direction this engine must never err in.
+ func testDuplicatedDaysCannotBuyANarrowerIntervalOrHigherConfidence() throws {
+ let clean = history(days: 30)
+ let duped = clean + clean // every day twice
+ let a = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: clean))
+ let b = try XCTUnwrap(AdaptiveExpenditureEngine.estimate(days: duped))
+ XCTAssertEqual(a.upperKcal - a.lowerKcal, b.upperKcal - b.lowerKcal, accuracy: 1e-6,
+ "a duplicate must not narrow the interval")
+ XCTAssertEqual(a.confidence, b.confidence, "nor raise the confidence")
+ XCTAssertLessThan(b.lowerKcal, b.estimatedDailyKcal)
+ XCTAssertGreaterThan(b.upperKcal, b.estimatedDailyKcal)
+ }
+
+ /// A bad day key must not be read as 1970 and stretch the window by twenty thousand days.
+ func testUnparseableDayKeyIsRefusedNotTreatedAs1970() {
+ XCTAssertNil(AdaptiveExpenditureEngine.dayCount(from: "not-a-day", to: "2026-01-01"))
+ XCTAssertNil(AdaptiveExpenditureEngine.dayCount(from: "2026-01-01", to: ""))
+ }
+
+ /// Readings that all share one day cannot yield a slope; zero variance must return nil.
+ func testSingleDayWeightClusterHasNoSlope() {
+ XCTAssertNil(AdaptiveExpenditureEngine.leastSquaresSlope([(3, 80.0), (3, 80.5), (3, 79.5)]))
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AlarmReadbackTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AlarmReadbackTests.swift
new file mode 100644
index 0000000000..a54ca57792
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AlarmReadbackTests.swift
@@ -0,0 +1,65 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// #1706. The values here are the ones from the field log that produced the issue: an arm sent
+/// 2026-08-26 06:30 against a readback claiming 2045-06-10, on a phone with a 4.0 and a 5.0 registered.
+/// Twin of Kotlin `AlarmReadbackTest`.
+final class AlarmReadbackTests: XCTestCase {
+
+ private let sent = 1_787_682_600 // 2026-08-26 06:30 +12:00
+ private let reported = 2_380_672_980 // 2045-06-10 14:03 +12:00, what the strap reported back
+
+ func testSameStrapAndAgreeing() {
+ XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 5,
+ sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .matches)
+ }
+
+ func testSameStrapAtTheToleranceBoundary() {
+ XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 120,
+ sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .matches)
+ XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 121,
+ sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), .mismatch)
+ }
+
+ func testSameStrapAndDisagreeingIsTheOnlyRealRefusal() {
+ let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported,
+ sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a")
+ XCTAssertEqual(v, .mismatch)
+ XCTAssertTrue(AlarmReadback.countsAsRejection(v))
+ }
+
+ /// The field case: the readback can only come from the 4.0, the arm went to the active 5.0.
+ func testCrossStrapIsNotJudged() {
+ let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported,
+ sentDeviceId: "whoop-5mg", reportedDeviceId: "my-whoop")
+ XCTAssertEqual(v, .differentStrap)
+ XCTAssertFalse(AlarmReadback.countsAsRejection(v), "a strap that was never asked must not be blamed")
+ XCTAssertFalse(AlarmReadback.clearsRejectionStreak(v), "nor may it clear a real refusal")
+ }
+
+ /// Data written before attribution existed. Unknown is not the same as innocent.
+ func testMissingAttributionIsNotJudged() {
+ let pairs: [(String?, String?)] = [(nil, "whoop-a"), ("whoop-a", nil), (nil, nil), ("", "whoop-a")]
+ for (a, b) in pairs {
+ let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported,
+ sentDeviceId: a, reportedDeviceId: b)
+ XCTAssertEqual(v, .unattributed, "\(String(describing: a)) / \(String(describing: b))")
+ XCTAssertFalse(AlarmReadback.countsAsRejection(v))
+ XCTAssertFalse(AlarmReadback.clearsRejectionStreak(v))
+ }
+ }
+
+ func testOnlyAProvenAgreementClearsTheStreak() {
+ XCTAssertTrue(AlarmReadback.clearsRejectionStreak(.matches))
+ XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.mismatch))
+ XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.differentStrap))
+ XCTAssertFalse(AlarmReadback.clearsRejectionStreak(.unattributed))
+ }
+
+ func testSuffixShape() {
+ XCTAssertEqual(AlarmReadback.suffix(.matches), " ✓ matches")
+ XCTAssertEqual(AlarmReadback.suffix(.mismatch), " ⚠️ MISMATCH — strap didn't accept the time")
+ XCTAssertEqual(AlarmReadback.suffix(.differentStrap), " (readback is from a different strap — not comparable)")
+ XCTAssertEqual(AlarmReadback.suffix(.unattributed), " (no strap recorded for one of these — not comparable)")
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift
new file mode 100644
index 0000000000..c630e3a5ae
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift
@@ -0,0 +1,70 @@
+import Foundation
+import XCTest
+import WhoopProtocol
+import WhoopStore
+@testable import StrandAnalytics
+
+/// A whole day whose only sleep is HR-only, driven through `analyzeDay` (#1884).
+///
+/// The ORACLE twin of the Kotlin `AnalyticsEngineHrOnlyDayTest`. The unit tests around this one check the
+/// pieces — `SleepStagerHrOnlySessionsTests` that a session reports what it measured,
+/// `HrOnlyPhysiologyIsolationTests` that the day's physiology set is chosen the way the design says.
+/// Neither runs the whole function, and the gap between them is where #1884's own regression hid:
+/// `sleepHrOnly` was derived from `physiologySessions.isEmpty`, which means "every session was HR-only"
+/// ONLY while that set was an exclusion. Making it a preference means it can never be empty when
+/// `matched` is not, so the flag would have been pinned to false forever.
+///
+/// Nothing pinned that derivation: every other `sleepHrOnly` test supplies the flag as an INPUT (the
+/// carry, the coalesce, the migration, the note gate). This drives it as an OUTPUT.
+final class AnalyticsEngineHrOnlyDayTests: XCTestCase {
+
+ private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male")
+ private let day = "2026-07-27"
+
+ /// The same field-shaped generator the session tests use — 16 h awake around 74 +/- 11 bpm, then 8 h
+ /// asleep around 64 +/- 5 — anchored so the night runs 00:00-08:00 on `day`. NO gravity, which is what
+ /// makes the night HR-only: an unbonded strap streams standard HR and R-R and banks no motion.
+ private func streams() -> ([HRSample], [RRInterval]) {
+ let dayStart = AnalyticsEngine.dayStartUtcSeconds(day)
+ let t0 = dayStart - 16 * 3600
+ var hr: [HRSample] = []
+ var rr: [RRInterval] = []
+ for i in 0..<(16 * 3600) {
+ let bpm = 74 + Int(sin(Double(i) / 500.0) * 11)
+ hr.append(HRSample(ts: t0 + i, bpm: bpm))
+ rr.append(RRInterval(ts: t0 + i, rrMs: 60000 / bpm))
+ }
+ for j in 0..<(8 * 3600) {
+ let bpm = 64 + Int(sin(Double(j) / 900.0) * 5)
+ hr.append(HRSample(ts: dayStart + j, bpm: bpm))
+ rr.append(RRInterval(ts: dayStart + j, rrMs: 60000 / bpm))
+ }
+ return (hr, rr)
+ }
+
+ func testAllHrOnlyDayReportsMeasuredVitalsAndStillMarksItselfHrOnly() {
+ let (hr, rr) = streams()
+ // The production wiring: IntelligenceEngine stages the HR-only night and hands it to analyzeDay as
+ // `providedSleep`. Driving it the same way is the point — the enrichment call site that used to
+ // short-circuit on `hrOnly` only exists on this path.
+ let provided = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: [])
+ XCTAssertFalse(provided.isEmpty, "the HR-only spine must stage a night here")
+
+ let res = AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, profile: profile,
+ providedSleep: provided)
+
+ XCTAssertFalse(res.sleepSessions.isEmpty, "the staged night must reach the day's sessions")
+ XCTAssertTrue(res.sleepSessions.allSatisfy { $0.hrOnly },
+ "every session must be HR-only — this day has no gravity at all")
+
+ // #1884: measured, not discarded. Before the change both of these were nil by construction, which
+ // is what left Charge with `nilScore reason=missingInput`.
+ XCTAssertNotNil(res.daily.restingHr, "resting HR is HR-derived and must survive to the day row")
+ XCTAssertNotNil(res.daily.avgHrv,
+ "the HRV measured over this night's windows must survive to the day row")
+
+ // The regression guard. `sleepHrOnly` says "every session was staged from heart rate alone", and
+ // that remains TRUE of this day — the change made the vitals available, not the staging better.
+ XCTAssertEqual(res.daily.sleepHrOnly, true, "the day must still be marked HR-only")
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift
index e8fc28bbb5..70bd0004c0 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineTests.swift
@@ -474,6 +474,61 @@ final class AnalyticsEngineTests: XCTestCase {
.building, "high-efficiency night with near-zero deep+REM is low-confidence staging")
}
+ // Hypnogram coverage: a stage timeline that accounts for only part of the span it claims cannot earn
+ // a SOLID Rest. This is the case the other two guards structurally cannot see — see the real night
+ // reproduced in `testRestConfidenceCoverageCatchesTheNightH9AndGravityMiss`.
+ func testRestConfidenceCoverageDowngradesHoledHypnogram() {
+ let asleep = 8.0 * 3600.0
+ XCTAssertEqual(
+ ScoreConfidence.rest(hasSession: true, hasStagedSleep: true,
+ asleepSeconds: asleep, restorativeSeconds: asleep * 0.45,
+ efficiency: 0.80, stageCoverage: 0.23),
+ .building, "a timeline covering 23% of its span cannot earn a SOLID Rest")
+ }
+
+ func testRestConfidenceCoverageKeepsSolidWhenTimelineCoversItsSpan() {
+ let asleep = 8.0 * 3600.0
+ XCTAssertEqual(
+ ScoreConfidence.rest(hasSession: true, hasStagedSleep: true,
+ asleepSeconds: asleep, restorativeSeconds: asleep * 0.45,
+ efficiency: 0.80, stageCoverage: 1.0),
+ .solid)
+ }
+
+ /// Unknown coverage must fail OPEN — nil is "not measured", never "measured badly". Omitting the
+ /// argument entirely (every pre-existing caller) must behave identically.
+ func testRestConfidenceCoverageUnknownDoesNotDowngrade() {
+ let asleep = 8.0 * 3600.0
+ for coverage in [nil, Optional(1.0)] {
+ XCTAssertEqual(
+ ScoreConfidence.rest(hasSession: true, hasStagedSleep: true,
+ asleepSeconds: asleep, restorativeSeconds: asleep * 0.45,
+ efficiency: 0.80, stageCoverage: coverage),
+ .solid, "coverage \(String(describing: coverage)) must not downgrade")
+ }
+ }
+
+ /// The measured night this guard exists for (2026-08-18, Oura ring vs a paired WHOOP strap): the
+ /// hypnogram covered 140 of 601 minutes, so NOOP stored 70 minutes of sleep where the strap recorded
+ /// 494. It read SOLID because NEITHER existing guard applies — `gravitySparse` is false (Oura banks
+ /// no gravity at all, so `isGravitySparse` returns false for every ring night) and H9 needs
+ /// efficiency ≥ 0.85 against this night's 0.50. Pinned so a future refactor cannot quietly lose it.
+ func testRestConfidenceCoverageCatchesTheNightH9AndGravityMiss() {
+ let asleep = 70.0 * 60.0
+ let restorative = asleep * 0.45 // ordinary share over the part that DID arrive
+ XCTAssertEqual(
+ ScoreConfidence.rest(hasSession: true, hasStagedSleep: true,
+ asleepSeconds: asleep, restorativeSeconds: restorative,
+ efficiency: 0.50, gravitySparse: false),
+ .solid, "precondition: without coverage this night reads SOLID — that is the bug")
+ XCTAssertEqual(
+ ScoreConfidence.rest(hasSession: true, hasStagedSleep: true,
+ asleepSeconds: asleep, restorativeSeconds: restorative,
+ efficiency: 0.50, gravitySparse: false,
+ stageCoverage: 140.0 / 601.0),
+ .building, "with coverage it is correctly low-confidence")
+ }
+
func testRestConfidenceH9KeepsSolidWhenRestorativeHealthy() {
// Same high-efficiency night but a healthy ~45% restorative share → stays solid.
let asleep = 8.0 * 3600.0
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheStreamWitnessTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheStreamWitnessTests.swift
new file mode 100644
index 0000000000..ecfa40ee25
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheStreamWitnessTests.swift
@@ -0,0 +1,97 @@
+import Foundation
+import XCTest
+import WhoopProtocol
+import WhoopStore
+@testable import StrandAnalytics
+
+/// The per-day reuse key must witness every stream `analyzeDay` scores, not HR alone (#29).
+///
+/// A WHOOP history offload does not commit its channels together: HR can land first and R-R (or
+/// respiration, or SpO2) minutes later, and an offloaded HR row duplicating a live one is dropped by
+/// `ON CONFLICT DO NOTHING`. So a banked night can be scored once from HR alone and then have its R-R
+/// arrive with the night's HR `(count, maxTs)` completely unmoved. The whole-pass gate already sees that —
+/// `WhoopStore.analysisFingerprint` covers every raw stream — so the pass runs; it was the PER-DAY key that
+/// said "reuse", re-serving the HRV-less scan for the rest of the session, force refreshes included.
+///
+/// `AnalyzeRecentDayCacheTests` pins the key's contract on literal witnesses. This one drives the two real
+/// halves against each other — `analyzeDay` for what the night actually scores, a real store for what the
+/// witness actually reports — so neither half can drift into agreeing with a broken other half.
+/// Swift-only: it drives a real `WhoopStore`, and Kotlin pins the same contract from the key side in
+/// `AnalyzeRecentDayCacheTest`.
+final class AnalyzeRecentDayCacheStreamWitnessTests: XCTestCase {
+
+ private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male")
+ private let day = "2026-07-27"
+ private let owner = "whoop-A"
+
+ /// 16 h awake around 74 +/- 11 bpm then 8 h asleep around 64 +/- 5, anchored so the night runs
+ /// 00:00-08:00 on `day` — the same field-shaped generator `AnalyticsEngineHrOnlyDayTests` uses. No
+ /// gravity, which is the shape of the banked night in the report: a strap that streamed HR and R-R.
+ private func streams() -> ([HRSample], [RRInterval]) {
+ let dayStart = AnalyticsEngine.dayStartUtcSeconds(day)
+ let t0 = dayStart - 16 * 3600
+ var hr: [HRSample] = []
+ var rr: [RRInterval] = []
+ for i in 0..<(16 * 3600) {
+ let bpm = 74 + Int(sin(Double(i) / 500.0) * 11)
+ hr.append(HRSample(ts: t0 + i, bpm: bpm))
+ rr.append(RRInterval(ts: t0 + i, rrMs: 60000 / bpm))
+ }
+ for j in 0..<(8 * 3600) {
+ let bpm = 64 + Int(sin(Double(j) / 900.0) * 5)
+ hr.append(HRSample(ts: dayStart + j, bpm: bpm))
+ rr.append(RRInterval(ts: dayStart + j, rrMs: 60000 / bpm))
+ }
+ return (hr, rr)
+ }
+
+ private func scan(hr: [HRSample], rr: [RRInterval]) -> AnalyticsEngine.DayResult {
+ AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, profile: profile,
+ providedSleep: SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: []))
+ }
+
+ /// R-R landing after HR changes what the night scores, so it must change the reuse key.
+ func testLateRrChangesTheNightAndInvalidatesTheKey() async throws {
+ let (hr, rr) = streams()
+ let from = hr.map(\.ts).min() ?? 0
+ let to = hr.map(\.ts).max() ?? 0
+
+ // What the night scores. Pass 1: HR is committed, R-R has not landed. Pass 2: same HR, plus R-R.
+ let hrOnly = scan(hr: hr, rr: [])
+ let withRr = scan(hr: hr, rr: rr)
+ XCTAssertNil(hrOnly.daily.avgHrv, "an R-R-less night has no nightly HRV to bank")
+ XCTAssertNotNil(withRr.daily.avgHrv, "once R-R lands the night scores an HRV — the cached scan is stale")
+
+ // What the store reports, over the SAME two commits.
+ let store = try await WhoopStore.inMemory()
+ try await store.upsertDevice(id: owner, mac: nil, name: nil)
+ _ = try await store.insert(Streams(hr: hr), deviceId: owner)
+ let beforeFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to)
+ let beforeStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to)
+
+ _ = try await store.insert(Streams(rr: rr), deviceId: owner)
+ let afterFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to)
+ let afterStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to)
+
+ // The HR witness alone cannot see this commit — that is the whole defect.
+ XCTAssertEqual(beforeFp.count, afterFp.count)
+ XCTAssertEqual(beforeFp.maxTs, afterFp.maxTs)
+ XCTAssertNotEqual(beforeStreams, afterStreams, "the R-R commit must move the other-stream witness")
+
+ let before = AnalyzeRecentDayCache.cacheKey(
+ owner: owner, hrCount: beforeFp.count, hrMaxTs: beforeFp.maxTs, skinAnchorRaw: nil,
+ streams: beforeStreams, hrvWindowDetail: false)
+ let after = AnalyzeRecentDayCache.cacheKey(
+ owner: owner, hrCount: afterFp.count, hrMaxTs: afterFp.maxTs, skinAnchorRaw: nil,
+ streams: afterStreams, hrvWindowDetail: false)
+ XCTAssertNotEqual(before, after,
+ "the night's R-R arrived — reusing the HRV-less scan is the #29 defect")
+
+ // And a pass that commits nothing must still reuse: the fix must not re-score every night forever.
+ let idleFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to)
+ let idleStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to)
+ XCTAssertEqual(after, AnalyzeRecentDayCache.cacheKey(
+ owner: owner, hrCount: idleFp.count, hrMaxTs: idleFp.maxTs, skinAnchorRaw: nil,
+ streams: idleStreams, hrvWindowDetail: false))
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift
index b95f531504..f9b8910b21 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyzeRecentDayCacheTests.swift
@@ -6,18 +6,22 @@ import XCTest
/// (the exact key STRING may differ across platforms — the cache is in-memory and per-platform — but the
/// set of changes that must / must not invalidate a reused day is a shared contract).
final class AnalyzeRecentDayCacheTests: XCTestCase {
+ /// A settled night's other-stream witness (`WhoopStore.dayStreamFingerprint`) — opaque to the key, so
+ /// these tests only need two values that differ.
+ private let baseStreams = "s1|p0:0|r120000:1700000000|x0:0|o0:0|g86400:1700000000|z0:0|t0:0|e0:0"
+
// Unchanged inputs → identical key → the day is reused.
func testStableInputsReuse() {
- let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
- let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
+ let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
+ let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
XCTAssertEqual(a, b)
}
// A new HR row (count moves) OR a later newest-ts must invalidate — the day changed.
func testHrChangeInvalidates() {
- let base = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
- let moreRows = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_001, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
- let laterTs = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_060, skinAnchorRaw: 1290, hrvWindowDetail: false)
+ let base = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
+ let moreRows = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_001, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
+ let laterTs = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_060, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(base, moreRows)
XCTAssertNotEqual(base, laterTs)
}
@@ -25,17 +29,17 @@ final class AnalyzeRecentDayCacheTests: XCTestCase {
// A shifted window-wide skin anchor (another night's skin changed the 4.0 median) must invalidate even
// when this night's HR fingerprint is identical — the skin conversion changed.
func testAnchorShiftInvalidates() {
- let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
- let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290.5, hrvWindowDetail: false)
+ let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
+ let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290.5, streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(a, b)
}
// A 5/MG night (no anchor) is a distinct, self-consistent key: nil reuses nil, and nil ≠ a real anchor
// (so a 4.0 and a 5/MG night with the same fingerprint never alias to each other's scan).
func testNilAnchorDistinctButStable() {
- let nilA = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, hrvWindowDetail: false)
- let nilB = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, hrvWindowDetail: false)
- let real = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 0, hrvWindowDetail: false)
+ let nilA = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, streams: baseStreams, hrvWindowDetail: false)
+ let nilB = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, streams: baseStreams, hrvWindowDetail: false)
+ let real = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 0, streams: baseStreams, hrvWindowDetail: false)
XCTAssertEqual(nilA, nilB)
XCTAssertNotEqual(nilA, real)
}
@@ -45,8 +49,8 @@ final class AnalyzeRecentDayCacheTests: XCTestCase {
// count+maxTs for the same window. The owner id is part of the key, so it never falsely reuses one
// strap's scan for the other.
func testDifferentOwnerInvalidates() {
- let whoop4 = AnalyzeRecentDayCache.cacheKey(owner: "whoop4-A", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
- let whoop5 = AnalyzeRecentDayCache.cacheKey(owner: "whoop5-B", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
+ let whoop4 = AnalyzeRecentDayCache.cacheKey(owner: "whoop4-A", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
+ let whoop5 = AnalyzeRecentDayCache.cacheKey(owner: "whoop5-B", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(whoop4, whoop5)
}
@@ -62,10 +66,42 @@ final class AnalyzeRecentDayCacheTests: XCTestCase {
func testTheHrvDetailDayIsNotReusedAsAnOrdinaryNight() {
let detail = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000,
hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
- hrvWindowDetail: true)
+ streams: baseStreams, hrvWindowDetail: true)
let summary = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000,
hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
- hrvWindowDetail: false)
+ streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(detail, summary, "the detail day must invalidate when it stops being today")
}
+
+ /// #29: a stream that is not HR moving must invalidate, even with the HR fingerprint frozen.
+ ///
+ /// A history offload delivers channels independently and drops offloaded HR rows that duplicate live
+ /// ones (`ON CONFLICT DO NOTHING`), so a night scored once from HR alone can gain its R-R — or its
+ /// respiration, SpO2, gravity, steps, skin temp, events, or a PPG-derived HR second — with this night's
+ /// `hrCount`/`hrMaxTs` completely unmoved. Keyed on HR alone that said "reuse" and re-served the
+ /// HRV-less scan for the rest of the session, force refreshes included.
+ ///
+ /// Byte-parity twin of Kotlin `aStreamArrivingAfterHrInvalidatesTheReusedNight`.
+ func testAStreamArrivingAfterHrInvalidatesTheReusedNight() {
+ // Same night, same HR rows, same anchor — only the other-stream witness moved.
+ let hrOnly = AnalyzeRecentDayCache.cacheKey(
+ owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
+ streams: "s1|p0:0|r0:0|x0:0|o0:0|g0:0|z0:0|t0:0|e0:0", hrvWindowDetail: false)
+ let rrLanded = AnalyzeRecentDayCache.cacheKey(
+ owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
+ streams: "s1|p0:0|r120000:1700000000|x0:0|o0:0|g0:0|z0:0|t0:0|e0:0", hrvWindowDetail: false)
+ XCTAssertNotEqual(hrOnly, rrLanded, "the night's R-R arrived — its cached scan has no HRV in it")
+ }
+
+ /// The other half of the same contract: a witness that did NOT move must still reuse. #29's fix must
+ /// not turn every pass into a full re-score — that is the drain #1005 closed.
+ func testUnchangedStreamWitnessStillReuses() {
+ let a = AnalyzeRecentDayCache.cacheKey(
+ owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
+ streams: baseStreams, hrvWindowDetail: false)
+ let b = AnalyzeRecentDayCache.cacheKey(
+ owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
+ streams: baseStreams, hrvWindowDetail: false)
+ XCTAssertEqual(a, b)
+ }
}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BackupSettingsLinuxTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BackupSettingsLinuxTests.swift
new file mode 100644
index 0000000000..f88a0cc873
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BackupSettingsLinuxTests.swift
@@ -0,0 +1,23 @@
+import XCTest
+import WhoopStore
+
+/// Exercises the Corelibs Foundation NSNumber distinction used by BackupSettings on Linux.
+final class BackupSettingsLinuxTests: XCTestCase {
+ func testBooleanAndNumericJSONValuesKeepDistinctContracts() throws {
+ let payload = Data(#"{"profile.age":true,"profile.hrMax":1,"profile.weightKg":false,"profile.heightCm":1}"#.utf8)
+
+ let decoded = BackupSettings.decode(payload)
+
+ XCTAssertNil(decoded["profile.age"], "A JSON boolean must not be accepted as numeric 1")
+ XCTAssertNil(decoded["profile.weightKg"], "A JSON boolean must not be accepted as numeric 0")
+ XCTAssertEqual(decoded["profile.hrMax"] as? Int, 1)
+ XCTAssertEqual(decoded["profile.heightCm"] as? Double, 1.0)
+
+ let roundTrip = try XCTUnwrap(BackupSettings.encode(decoded))
+ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: roundTrip) as? [String: Any])
+ XCTAssertEqual((json["profile.hrMax"] as? NSNumber)?.intValue, 1)
+ XCTAssertEqual((json["profile.heightCm"] as? NSNumber)?.doubleValue, 1.0)
+ XCTAssertNil(json["profile.age"])
+ XCTAssertNil(json["profile.weightKg"])
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesRecentHrvCoverageTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesRecentHrvCoverageTests.swift
new file mode 100644
index 0000000000..4d9a98c381
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BaselinesRecentHrvCoverageTests.swift
@@ -0,0 +1,50 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// The counts behind "N of the last M nights recorded no HRV".
+///
+/// The line only helps if both numbers are right, and both are Ints with the same units, so an inverted
+/// pair reads as "5 of the last 3 nights" — absurd to a person, invisible to a compiler. These pin the
+/// order as well as the arithmetic. Byte-identical twin: Kotlin `RecentHrvCoverageTest`.
+final class BaselinesRecentHrvCoverageTests: XCTestCase {
+
+ /// The reported shape: five observed nights, three of them empty.
+ func testCountsObservedNightsAndTheEmptyOnesAmongThem() {
+ let days = ["2026-09-02", "2026-09-03", "2026-09-04", "2026-09-05", "2026-09-06"]
+ let hrv: [Double?] = [44.0, nil, nil, 47.0, nil]
+ let cov = Baselines.recentHrvCoverage(dayKeys: days, nightlyHrv: hrv, today: "2026-09-06")
+ XCTAssertEqual(cov.observed, 5)
+ XCTAssertEqual(cov.missing, 3)
+ }
+
+ /// Only days the app has a row for, so a fresh install is not charged for nights it never saw.
+ func testADayOutsideTheWindowIsNotObserved() {
+ let cov = Baselines.recentHrvCoverage(dayKeys: ["2026-08-01", "2026-09-06"],
+ nightlyHrv: [nil, nil], today: "2026-09-06", window: 14)
+ XCTAssertEqual(cov.observed, 1, "the August night is 36 days back, outside the window")
+ XCTAssertEqual(cov.missing, 1)
+ }
+
+ /// A future-dated row cannot be a night that already happened.
+ func testADayAfterTodayIsIgnored() {
+ let cov = Baselines.recentHrvCoverage(dayKeys: ["2026-09-07"], nightlyHrv: [nil],
+ today: "2026-09-06")
+ XCTAssertEqual(cov.observed, 0)
+ XCTAssertEqual(cov.missing, 0)
+ }
+
+ /// Every night counted: the caller must then say nothing extra.
+ func testACompleteWindowReportsNothingMissing() {
+ let cov = Baselines.recentHrvCoverage(dayKeys: ["2026-09-05", "2026-09-06"],
+ nightlyHrv: [50.0, 51.0], today: "2026-09-06")
+ XCTAssertEqual(cov.observed, 2)
+ XCTAssertEqual(cov.missing, 0)
+ }
+
+ /// No rows, and an unparseable today, both yield zeroes rather than a fabricated count.
+ func testEmptyAndUnparseableInputsAreZero() {
+ XCTAssertEqual(Baselines.recentHrvCoverage(dayKeys: [], nightlyHrv: [], today: "2026-09-06").observed, 0)
+ XCTAssertEqual(Baselines.recentHrvCoverage(dayKeys: ["2026-09-06"], nightlyHrv: [nil],
+ today: "not-a-day").observed, 0)
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift
index 434cd453c2..a2fa1c77ab 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/BehaviorInsightsTests.swift
@@ -14,7 +14,7 @@ final class BehaviorInsightsTests: XCTestCase {
"d13": 70, "d14": 68,
]
let behaviorDays: Set = ["d01", "d02", "d03", "d04", "d05", "d06"]
- let e = BehaviorInsights.effect(behaviorDays: behaviorDays, outcomeByDay: outcome,
+ let e = BehaviorInsights.effect(behaviorDays: behaviorDays, controlDays: Set(outcome.keys).subtracting(behaviorDays), outcomeByDay: outcome,
behavior: "Alcohol", outcome: "Recovery")!
XCTAssertEqual(e.nWith, 6)
XCTAssertEqual(e.nWithout, 8)
@@ -34,6 +34,7 @@ final class BehaviorInsightsTests: XCTestCase {
"f": 70, "g": 72, "h": 68, "i": 71, "j": 69, // without (mean 70)
]
let e = BehaviorInsights.effect(behaviorDays: ["a", "b", "c", "d", "e"],
+ controlDays: Set(outcome.keys).subtracting(["a", "b", "c", "d", "e"]),
outcomeByDay: outcome,
behavior: "Meditation", outcome: "Recovery")!
XCTAssertEqual(e.delta, 10.0, accuracy: 1e-9)
@@ -46,10 +47,12 @@ final class BehaviorInsightsTests: XCTestCase {
// Behavior logged every day → no "without" group.
let outcome: [String: Double] = ["a": 60, "b": 61, "c": 62]
XCTAssertNil(BehaviorInsights.effect(behaviorDays: ["a", "b", "c"],
+ controlDays: Set(outcome.keys).subtracting(["a", "b", "c"]),
outcomeByDay: outcome,
behavior: "X", outcome: "Recovery"))
// Behavior never logged → no "with" group.
XCTAssertNil(BehaviorInsights.effect(behaviorDays: [],
+ controlDays: Set(outcome.keys).subtracting([]),
outcomeByDay: outcome,
behavior: "X", outcome: "Recovery"))
}
@@ -58,6 +61,7 @@ final class BehaviorInsightsTests: XCTestCase {
// "z" is in behaviorDays but has no outcome value → not counted in nWith.
let outcome: [String: Double] = ["a": 60, "b": 62, "c": 70, "d": 72]
let e = BehaviorInsights.effect(behaviorDays: ["a", "b", "z"],
+ controlDays: Set(outcome.keys).subtracting(["a", "b", "z"]),
outcomeByDay: outcome,
behavior: "X", outcome: "Recovery")!
XCTAssertEqual(e.nWith, 2) // a, b only
@@ -74,6 +78,7 @@ final class BehaviorInsightsTests: XCTestCase {
"o1": 70, "o2": 71, "o3": 69, "o4": 70,
]
let small = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4"],
+ controlDays: Set(smallOutcome.keys).subtracting(["w1", "w2", "w3", "w4"]),
outcomeByDay: smallOutcome,
behavior: "X", outcome: "Recovery")!
XCTAssertLessThan(small.pApprox, 0.05) // strong evidence numerically…
@@ -86,6 +91,7 @@ final class BehaviorInsightsTests: XCTestCase {
"o1": 70, "o2": 71, "o3": 69, "o4": 70, "o5": 70,
]
let big = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4", "w5"],
+ controlDays: Set(bigOutcome.keys).subtracting(["w1", "w2", "w3", "w4", "w5"]),
outcomeByDay: bigOutcome,
behavior: "X", outcome: "Recovery")!
XCTAssertEqual(Swift.min(big.nWith, big.nWithout), 5)
@@ -99,6 +105,7 @@ final class BehaviorInsightsTests: XCTestCase {
"o1": 64, "o2": 72, "o3": 61, "o4": 74, "o5": 67, "o6": 69,
]
let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4", "w5", "w6"],
+ controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3", "w4", "w5", "w6"]),
outcomeByDay: outcome,
behavior: "X", outcome: "Recovery")!
XCTAssertGreaterThan(e.pApprox, 0.05) // no separation → weak evidence
@@ -120,7 +127,13 @@ final class BehaviorInsightsTests: XCTestCase {
// Tiny-but-significant-impossible: 2 days only.
let tiny: Set = ["d3", "d9"]
+ // Predates the Yes/No split and tests the ranking math, so it keeps its original partition
+ // by declaring the complement as controls explicitly.
+ let all = Set(outcome.keys)
let ranked = BehaviorInsights.rank(behaviors: ["Strong": strong, "Weak": weak, "Tiny": tiny],
+ controls: ["Strong": all.subtracting(strong),
+ "Weak": all.subtracting(weak),
+ "Tiny": all.subtracting(tiny)],
outcomeByDay: outcome, outcome: "Recovery")
XCTAssertEqual(ranked.count, 3)
XCTAssertEqual(ranked.first?.behavior, "Strong") // significant + largest |d|
@@ -135,9 +148,15 @@ final class BehaviorInsightsTests: XCTestCase {
func testRankDropsUncomputableBehaviors() {
let outcome: [String: Double] = ["a": 60, "b": 62, "c": 70, "d": 72]
// "AllDays" covers every day → no without group → dropped.
+ // Predates the Yes/No split and tests the ranking math, so it keeps its original partition
+ // by declaring the complement as controls explicitly.
+ let all = Set(outcome.keys)
let ranked = BehaviorInsights.rank(behaviors: [
"AllDays": ["a", "b", "c", "d"],
"Half": ["a", "b"],
+ ], controls: [
+ "AllDays": all.subtracting(["a", "b", "c", "d"]),
+ "Half": all.subtracting(["a", "b"]),
], outcomeByDay: outcome, outcome: "Recovery")
XCTAssertEqual(ranked.count, 1)
XCTAssertEqual(ranked.first?.behavior, "Half")
@@ -153,6 +172,7 @@ final class BehaviorInsightsTests: XCTestCase {
"o1": 78, "o2": 82, "o3": 80, "o4": 79, "o5": 81, // mean 80
]
let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3", "w4", "w5"],
+ controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3", "w4", "w5"]),
outcomeByDay: outcome,
behavior: "Alcohol", outcome: "Recovery")!
let s = BehaviorInsights.sentence(e)
@@ -165,6 +185,7 @@ final class BehaviorInsightsTests: XCTestCase {
"o1": 49, "o2": 51, "o3": 50, // mean 50
]
let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3"],
+ controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3"]),
outcomeByDay: outcome,
behavior: "Meditation", outcome: "Recovery")!
let s = BehaviorInsights.sentence(e)
@@ -179,10 +200,62 @@ final class BehaviorInsightsTests: XCTestCase {
"o1": 0, "o2": 0, "o3": 0,
]
let e = BehaviorInsights.effect(behaviorDays: ["w1", "w2", "w3"],
+ controlDays: Set(outcome.keys).subtracting(["w1", "w2", "w3"]),
outcomeByDay: outcome,
behavior: "X", outcome: "HRV")!
XCTAssertNil(e.pctChange)
let s = BehaviorInsights.sentence(e)
XCTAssertEqual(s, "On days you logged ‘X’, HRV was 5.0 higher (avg 5 vs 0, n=3 vs 3).")
}
+ // MARK: - Unlogged days are not answers (the Reddit report)
+
+ /// "If I didn't track something for 100 days, NOOP takes that as a NO for 100 days, whereas it simply
+ /// was not logged at all." Reported by a user on Reddit, and it was exactly what the split did.
+ ///
+ /// Twin of Kotlin `EffectRankerTest.daysWithNoJournalRowAreNotControls`.
+ func testDaysWithNoJournalRowAreNotControls() {
+ var outcome: [String: Double] = [:]
+ var yes: Set = []
+ var no: Set = []
+ for d in 1...6 { let k = "y\(d)"; yes.insert(k); outcome[k] = Double(58 + d) }
+ for d in 1...6 { let k = "n\(d)"; no.insert(k); outcome[k] = Double(68 + d) }
+ // Never opened the journal on these, and their values sit far from BOTH answered groups.
+ for d in 1...40 { outcome["u\(d)"] = Double(20 + (d % 3)) }
+
+ let e = BehaviorInsights.effect(behaviorDays: yes, controlDays: no,
+ outcomeByDay: outcome,
+ behavior: "Alcohol", outcome: "Recovery")!
+ // Controls are the six NO days only. Were the 40 unlogged days leaking in, nWithout would be 46
+ // and meanWithout would be dragged towards 20.
+ XCTAssertEqual(e.nWith, 6)
+ XCTAssertEqual(e.nWithout, 6)
+ XCTAssertGreaterThan(e.meanWithout, 60.0)
+ }
+
+ /// A behaviour the user only ever ticks Yes has no control group, so there is no comparison to make
+ /// and the honest answer is none. Twin of Kotlin `aBehaviourNeverLoggedNoYieldsNothing`.
+ func testBehaviourNeverLoggedNoYieldsNothing() {
+ var outcome: [String: Double] = [:]
+ var yes: Set = []
+ for d in 1...10 { let k = "y\(d)"; yes.insert(k); outcome[k] = Double(50 + d) }
+ for d in 1...10 { outcome["u\(d)"] = Double(80 + d) }
+
+ XCTAssertNil(BehaviorInsights.effect(behaviorDays: yes, controlDays: [],
+ outcomeByDay: outcome,
+ behavior: "Alcohol", outcome: "Recovery"))
+ }
+
+ /// rank() fails CLOSED on a caller that forgets the controls: no insight, rather than a wrong one
+ /// measured against every day the user never opened the journal. Twin of Kotlin
+ /// `rankWithoutControlsProducesNothingRatherThanGuessing`.
+ func testRankWithoutControlsProducesNothingRatherThanGuessing() {
+ var outcome: [String: Double] = [:]
+ var yes: Set = []
+ for d in 1...10 { let k = "y\(d)"; yes.insert(k); outcome[k] = Double(50 + d) }
+ for d in 1...20 { outcome["u\(d)"] = Double(75 + (d % 4)) }
+
+ XCTAssertTrue(BehaviorInsights.rank(behaviors: ["Alcohol": yes], controls: [:],
+ outcomeByDay: outcome, outcome: "Recovery").isEmpty)
+ }
+
}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift
index 51e95f0673..e36aea3eaf 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CaptureCompletenessTests.swift
@@ -19,7 +19,7 @@ final class CaptureCompletenessTests: XCTestCase {
[import] import stage=sleep rowsIn=10 rowsOut=10
[steps] stepsRaw day=2026-06-28 counterSamples=4 deltas kept=3 dropped=0
[battery] bank soc=82.0 t=1700000000s
- [recovery] charge term hrv z=0.20 w=0.40 (higher HRV is better)
+ [recovery] charge day=2026-06-28 term hrv z=0.20 w=0.40 (higher HRV is better)
[hrv] hrv rmssd=42.10ms sdnn=55.00ms meanNN=900.00ms
[universal] dayOwner day=2026-06-28 readId=my-whoop writeActiveId=my-whoop hrRows=120 provenance=measured
"""
@@ -126,9 +126,16 @@ final class CaptureCompletenessTests: XCTestCase {
}
func testTokenMapMatchesEmitterTokensExactly() {
- // Guard against a silent emitter rename: each token must be the verbatim leading text the live
- // emitter writes. These literals mirror the *Trace files (verified at authoring time).
- XCTAssertEqual(CaptureCompleteness.expectedTokens(for: .recovery).first, "charge term")
+ // Guard against a silent emitter rename: each token must be the verbatim leading text that
+ // reaches the REPORT.
+ //
+ // "mirror the *Trace files" is how the recovery token came to be wrong for as long as it was.
+ // RecoveryScorer+Trace really does write "charge term ...", but IntelligenceEngine re-emits
+ // every one of those lines as `charge day= ` + the body before it is logged, so the
+ // literal this guard was protecting could not occur in a single report. A token has to mirror
+ // the END of the pipeline, not the start, or a guard like this passes while the thing it
+ // guards is dead.
+ XCTAssertEqual(CaptureCompleteness.expectedTokens(for: .recovery).first, "charge day=")
XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .hrv).contains("hrv rmssd="))
XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .steps).contains("stepsRaw"))
XCTAssertTrue(CaptureCompleteness.expectedTokens(for: .universal).contains("dayOwner "))
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift
index 6c2f57b17b..88cb32be18 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CircadianEngineTests.swift
@@ -129,26 +129,159 @@ final class CircadianEngineTests: XCTestCase {
/// `minRelativeAmplitude` gates on `amplitude / |mesor|`. Against a signal carrying a ~45-75 bpm DC
/// offset that makes the real bar an ABSOLUTE `0.10 x mesor` bpm — which these pin.
///
- /// The pair that matters is the last two: the SAME 5 bpm swing is arrhythmic at a 65 bpm mesor and
- /// readable at 45. The bar scales WITH the mesor, so a low-resting wearer faces a LOWER absolute
- /// requirement, not a higher one. #982 raised the opposite concern — that the gate penalises the
- /// fittest — and that only holds for someone whose amplitude is disproportionately small for their
- /// mesor. Pinned here so the direction is a fact rather than an argument, for whoever re-tunes it.
+ /// These USED to pin the relative bar alone, including that the SAME 5 bpm swing was arrhythmic at a
+ /// 65 bpm mesor and readable at 45 — the same rhythm judged by the baseline rather than by itself.
+ /// `minAbsoluteAmplitudeBpm` removed that split; what remains pinned is that a proportional swing
+ /// still passes, a genuinely flat one still fails, and the verdict no longer moves with the mesor.
private func confidence(mesor: Double, amp: Double) -> CircadianEngine.PhaseConfidence {
CircadianEngine.estimatePhase(bins: profile(mesor: mesor, amp: amp, acrophase: 16),
daysObserved: CircadianEngine.goodDaysForFit,
habitualWakeHour: 7)!.confidence
}
- func testTypicalMesorNeedsAboutSixAndAHalfBpmOfSwing() {
+ func testAProportionalSwingStillPasses() {
XCTAssertNotEqual(confidence(mesor: 65, amp: 8), .unreadable) // 0.123 - clears 0.10
- XCTAssertEqual(confidence(mesor: 65, amp: 5), .unreadable) // 0.077 - does not
}
- func testTheSameSwingIsReadableAtALowerMesor() {
- // 5 bpm: arrhythmic at 65, rhythmic at 45. The gate favours the low-resting wearer.
- XCTAssertEqual(confidence(mesor: 65, amp: 5), .unreadable)
- XCTAssertNotEqual(confidence(mesor: 45, amp: 5), .unreadable) // 0.111
- XCTAssertEqual(confidence(mesor: 45, amp: 4), .unreadable) // 0.089
+ /// The inconsistency the absolute floor removes: one rhythm, three baselines, one verdict.
+ func testTheSameSwingNoLongerDependsOnTheBaseline() {
+ XCTAssertNotEqual(confidence(mesor: 45, amp: 5), .unreadable)
+ XCTAssertNotEqual(confidence(mesor: 65, amp: 5), .unreadable)
+ XCTAssertNotEqual(confidence(mesor: 80, amp: 5), .unreadable)
+ }
+
+ /// A genuinely flat rhythm is still refused — the floor widens the gate, it does not remove it.
+ func testAFlatRhythmIsStillArrhythmicOnBothMeasures() {
+ XCTAssertEqual(confidence(mesor: 45, amp: 4), .unreadable) // 0.089, 4.0 bpm
+ XCTAssertEqual(confidence(mesor: 65, amp: 4), .unreadable) // 0.062, 4.0 bpm
+ XCTAssertEqual(confidence(mesor: 74.7, amp: 3), .unreadable)
+ }
+
+ /// The absolute floor's own boundary, isolated: at a 74.7 bpm mesor the relative test cannot pass
+ /// either value, so only `minAbsoluteAmplitudeBpm` decides. Expressed RELATIVE to the constant, with a
+ /// 0.1 bpm margin rather than the exact value — the cosinor recovers amplitude to ~1e-9, and sitting
+ /// exactly on `>=` would pin float recovery rather than the threshold.
+ func testTheAbsoluteFloorIsWhereItSays() {
+ let floor = CircadianEngine.minAbsoluteAmplitudeBpm
+ XCTAssertNotEqual(confidence(mesor: 74.7, amp: floor + 0.1), .unreadable)
+ XCTAssertEqual(confidence(mesor: 74.7, amp: floor - 0.1), .unreadable)
+ }
+
+ /// The widening must not hand a thinner fit a FIRMER label. A rhythm admitted only by the absolute
+ /// floor caps at `.wide`, which withholds `chronotype` — that names a category off an acrophase a
+ /// small swing pins loosely. A proportional rhythm still reaches `.solid`.
+ func testAnAbsoluteOnlyRhythmIsReadableButNotSolid() {
+ XCTAssertEqual(confidence(mesor: 74.7, amp: 5.5), .wide) // 0.073 - floor only
+ XCTAssertEqual(confidence(mesor: 65, amp: 8), .solid) // 0.123 - proportional
+ let wide = CircadianEngine.estimatePhase(bins: profile(mesor: 74.7, amp: 5.5, acrophase: 16),
+ daysObserved: CircadianEngine.goodDaysForFit,
+ habitualWakeHour: 7)!
+ XCTAssertNil(CircadianEngine.chronotype(wide), "a floor-only fit must not name a chronotype")
+ }
+
+ /// The measured wearer this change exists for: 5.5 bpm on a 74.7 bpm mesor, refused at 7.3% against
+ /// the 10% bar while its acrophase implied a textbook CBTmin near 04:06.
+ func testTheMeasuredWearerIsNoLongerSilenced() {
+ XCTAssertNotEqual(confidence(mesor: 74.7, amp: 5.5), .unreadable)
+ }
+
+ // MARK: - chronotype lean (absolute phase, not schedule-relative)
+
+ /// The boundaries and the circular case, asserted from this side too so the agreement is pinned on
+ /// both platforms rather than only in the Kotlin oracle. `late-evening-wrap` is the row that matters:
+ /// 23:30 is five hours BEFORE the 04:30 anchor, so it is a strong MORNING lean — a naive
+ /// `23.5 > 5.5` bucket would call it evening.
+ func testChronotypeBucketsAbsolutePhaseCircularly() {
+ XCTAssertEqual(CircadianEngine.chronotypeAnchorHour, 4.5, accuracy: 0,
+ "the anchor is derived from the engine's own constants, not hardcoded")
+ let cases: [(Double, CircadianEngine.Chronotype)] = [
+ (4.5, .intermediate), (3.5, .intermediate), (3.49, .morning),
+ (5.5, .intermediate), (5.51, .evening),
+ (23.5, .morning), (0.0, .morning), (12.0, .evening),
+ (16.5, .evening), (16.4, .evening), (16.6, .morning),
+ (28.5, .intermediate), (-1.0, .morning),
+ ]
+ for (hour, expected) in cases {
+ XCTAssertEqual(CircadianEngine.chronotype(tempMinHour: hour), expected,
+ "tempMinHour \(hour)")
+ }
+ }
+
+ /// A NAMED category reads as a fact about the person rather than a reading of the week, so it waits
+ /// for the strongest tier — unlike the continuous offset the card already shows at `.wide`.
+ func testChronotypeIsNamedOnlyForASolidFit() {
+ func estimate(_ confidence: CircadianEngine.PhaseConfidence) -> CircadianEngine.PhaseEstimate {
+ CircadianEngine.PhaseEstimate(tempMinHour: 23.5, acrophaseHours: 11.5,
+ offsetVsScheduleMinutes: 0, confidence: confidence, note: "")
+ }
+ XCTAssertEqual(CircadianEngine.chronotype(estimate(.solid)), .morning)
+ XCTAssertNil(CircadianEngine.chronotype(estimate(.wide)),
+ "a thin fit must not name a chronotype")
+ XCTAssertNil(CircadianEngine.chronotype(estimate(.unreadable)))
+ }
+
+ /// The schedule-relative offset CANNOT name a chronotype, which is why this buckets the absolute
+ /// phase instead. A consistent 03:00-11:00 sleeper is well aligned with their OWN schedule — offset
+ /// ~0 — while being strongly evening-type by the clock.
+ func testConsistentLateSleeperIsEveningTypeDespiteAZeroScheduleOffset() {
+ let alignedButLate = CircadianEngine.PhaseEstimate(
+ tempMinHour: 8.0, acrophaseHours: 20.0, offsetVsScheduleMinutes: 0,
+ confidence: .solid, note: "")
+ XCTAssertEqual(CircadianEngine.chronotype(alignedButLate), .evening)
+ }
+
+ /// The same table the Kotlin oracle pins, asserted from this side too. `typical` says the model is
+ /// sane: a 04:30 temperature minimum and an 8 h night put the ideal window at 23:00-07:00. The wrap
+ /// rows matter because the ideal bedtime routinely lands on the PREVIOUS day.
+ func testIdealSleepWindowPlacesTheWindowOnTheRing() {
+ let cases: [(Double, Double, (Double, Double)?)] = [
+ (4.5, 8.0, (23.0, 7.0)), (4.5, 5.0, (2.0, 7.0)), (4.5, 10.0, (21.0, 7.0)),
+ (7.0, 8.0, (1.5, 9.5)), (2.0, 8.0, (20.5, 4.5)),
+ (1.0, 8.0, (19.5, 3.5)), (23.0, 8.0, (17.5, 1.5)),
+ (4.5, 0.0, nil), (4.5, -1.0, nil), (4.5, 24.0, nil),
+ ]
+ for (tempMin, duration, expected) in cases {
+ let w = CircadianEngine.idealSleepWindow(tempMinHour: tempMin, durationHours: duration)
+ if let expected {
+ XCTAssertEqual(w?.bedHour ?? -1, expected.0, accuracy: 1e-12, "bed for \(tempMin)/\(duration)")
+ XCTAssertEqual(w?.wakeHour ?? -1, expected.1, accuracy: 1e-12, "wake for \(tempMin)/\(duration)")
+ } else {
+ XCTAssertNil(w, "an impossible duration cannot be placed on the ring: \(duration)")
+ }
+ }
+ }
+
+ /// The ideal arc takes the night's OWN length, so the dial compares PHASE alone — which keeps a sleep
+ /// DEBT from rendering as a body-clock problem.
+ func testIdealWindowSharesTheWakeAnchorAcrossDurations() {
+ let short = CircadianEngine.idealSleepWindow(tempMinHour: 4.5, durationHours: 5)
+ let long = CircadianEngine.idealSleepWindow(tempMinHour: 4.5, durationHours: 10)
+ XCTAssertEqual(short?.wakeHour, long?.wakeHour)
+ XCTAssertNotEqual(short?.bedHour, long?.bedHour)
+ }
+
+ /// The dial's caption quantity. `wrap-late` earns its place: a 23:00 temperature minimum puts the
+ /// ideal wake at 01:30, so waking at 02:30 is one hour LATE, not twenty-three hours early.
+ func testSleepWindowOffsetHours() {
+ let cases: [(Double, Double, Double)] = [
+ (4.5, 7.0, 0.0), (4.5, 8.0, 1.0), (4.5, 6.0, -1.0),
+ (23.0, 2.5, 1.0), (1.0, 23.0, -4.5), (4.5, 19.0, 12.0),
+ ]
+ for (tempMin, wake, expected) in cases {
+ XCTAssertEqual(CircadianEngine.sleepWindowOffsetHours(tempMinHour: tempMin, actualWakeHour: wake),
+ expected, accuracy: 1e-12, "tempMin \(tempMin) wake \(wake)")
+ }
+ }
+
+ /// The dial's caption and the card's existing headline measure DIFFERENT things: a consistent late
+ /// sleeper is well-aligned with their own schedule while sleeping hours from what their clock wants.
+ func testWindowOffsetAndScheduleOffsetAreDifferentQuantities() {
+ let windowOffset = CircadianEngine.sleepWindowOffsetHours(tempMinHour: 8.0, actualWakeHour: 6.5)
+ XCTAssertEqual(windowOffset, -4.0, accuracy: 1e-12)
+ let scheduleAligned = CircadianEngine.PhaseEstimate(
+ tempMinHour: 8.0, acrophaseHours: 20.0, offsetVsScheduleMinutes: 0,
+ confidence: .solid, note: "")
+ XCTAssertEqual(scheduleAligned.offsetVsScheduleMinutes, 0)
+ XCTAssertGreaterThan(abs(windowOffset), 1.0)
}
}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatLocaleTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatLocaleTests.swift
new file mode 100644
index 0000000000..84f157b066
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatLocaleTests.swift
@@ -0,0 +1,52 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// #1821: these assert against a REAL `DateFormatter`, not against the identifier string.
+///
+/// The hour-cycle locale is the mechanism that carries the setting into every formatter that never says
+/// "HH:mm" - the ones using `timeStyle = .short` or a `"jmm"` template. If the identifier syntax were
+/// wrong, Foundation would quietly fall back to the base locale and every one of those sites would keep
+/// showing the region default while the setting appeared to be applied. That failure is silent, so it has
+/// to be caught by exercising Foundation itself.
+final class ClockFormatLocaleTests: XCTestCase {
+ private func shortTime(_ base: String, uses24Hour: Bool) -> String {
+ let id = ClockFormat.hourCycleLocaleIdentifier(base: base, uses24Hour: uses24Hour)
+ let f = DateFormatter()
+ f.locale = Locale(identifier: id)
+ f.timeZone = TimeZone(identifier: "UTC")
+ f.timeStyle = .short
+ f.dateStyle = .none
+ // 2026-01-02 22:57 UTC — the reported "22:57" / "10:57 PM".
+ return f.string(from: Date(timeIntervalSince1970: 1_767_394_620))
+ }
+
+ func testTimeStyleShortHonoursTheForcedHourCycle() {
+ // en_GB is a 24-hour region: asking for 12-hour must actually change it.
+ XCTAssertTrue(shortTime("en_GB", uses24Hour: false).contains("10"),
+ "12-hour was requested on a 24-hour region and did not take effect")
+ XCTAssertTrue(shortTime("en_GB", uses24Hour: true).contains("22"))
+ // en_US is a 12-hour region: the reverse must work too, or the setting is one-directional.
+ XCTAssertTrue(shortTime("en_US", uses24Hour: true).contains("22"),
+ "24-hour was requested on a 12-hour region and did not take effect")
+ XCTAssertTrue(shortTime("en_US", uses24Hour: false).contains("10"))
+ }
+
+ /// The other half of the surface: templates containing `j`, which resolve the hour from the locale.
+ func testJTemplateHonoursTheForcedHourCycle() {
+ func viaTemplate(_ base: String, uses24Hour: Bool) -> String {
+ let id = ClockFormat.hourCycleLocaleIdentifier(base: base, uses24Hour: uses24Hour)
+ let f = DateFormatter()
+ f.locale = Locale(identifier: id)
+ f.timeZone = TimeZone(identifier: "UTC")
+ f.setLocalizedDateFormatFromTemplate("jmm")
+ return f.string(from: Date(timeIntervalSince1970: 1_767_394_620))
+ }
+ XCTAssertTrue(viaTemplate("en_GB", uses24Hour: false).contains("10"))
+ XCTAssertTrue(viaTemplate("en_US", uses24Hour: true).contains("22"))
+ }
+
+ func testIdentifierShape() {
+ XCTAssertEqual(ClockFormat.hourCycleLocaleIdentifier(base: "en_GB", uses24Hour: true), "en_GB@hours=h23")
+ XCTAssertEqual(ClockFormat.hourCycleLocaleIdentifier(base: "fr_FR", uses24Hour: false), "fr_FR@hours=h12")
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatTests.swift
new file mode 100644
index 0000000000..3388e14882
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ClockFormatTests.swift
@@ -0,0 +1,48 @@
+import XCTest
+@testable import StrandAnalytics
+
+final class ClockFormatTests: XCTestCase {
+ func testSystemDefersToTheDevice() {
+ XCTAssertTrue(ClockFormat.uses24Hour(preference: .system, systemUses24Hour: true))
+ XCTAssertFalse(ClockFormat.uses24Hour(preference: .system, systemUses24Hour: false))
+ }
+
+ func testAnExplicitChoiceOverridesTheDevice() {
+ // The whole point of the setting: a reader in a 24-hour region can ask for 12-hour and get it.
+ XCTAssertFalse(ClockFormat.uses24Hour(preference: .twelveHour, systemUses24Hour: true))
+ XCTAssertTrue(ClockFormat.uses24Hour(preference: .twentyFourHour, systemUses24Hour: false))
+ }
+
+ func testUnknownStoredValuesFallBackToSystemNotToAClock() {
+ XCTAssertEqual(ClockFormatPreference.from(stored: nil), .system)
+ XCTAssertEqual(ClockFormatPreference.from(stored: ""), .system)
+ XCTAssertEqual(ClockFormatPreference.from(stored: "24h"), .system,
+ "an unparseable value must not pin every reader to one clock")
+ XCTAssertEqual(ClockFormatPreference.from(stored: "twelveHour"), .twelveHour)
+ XCTAssertEqual(ClockFormatPreference.from(stored: "twentyFourHour"), .twentyFourHour)
+ }
+
+ /// The stored vocabulary is shared with Android, which writes these exact strings. Pinning the
+ /// literals here means renaming a case cannot silently make the two platforms disagree about what a
+ /// user's saved preference means.
+ func testStoredVocabularyIsPinned() {
+ XCTAssertEqual(ClockFormatPreference.system.rawValue, "system")
+ XCTAssertEqual(ClockFormatPreference.twelveHour.rawValue, "twelveHour")
+ XCTAssertEqual(ClockFormatPreference.twentyFourHour.rawValue, "twentyFourHour")
+ XCTAssertEqual(ClockFormatPreference.defaultsKey, "clockFormatPreference")
+ }
+
+ func testHourMinutePattern() {
+ XCTAssertEqual(ClockFormat.hourMinutePattern(uses24Hour: true), "HH:mm")
+ XCTAssertEqual(ClockFormat.hourMinutePattern(uses24Hour: false), "h:mm a")
+ }
+
+ /// The template must never be `j`: that is the locale-resolved hour, and routing the reader's choice
+ /// back through it is the bug this setting exists to fix.
+ func testHourMinuteTemplateNeverDefersToTheLocaleHour() {
+ XCTAssertEqual(ClockFormat.hourMinuteTemplate(uses24Hour: true), "Hmm")
+ XCTAssertEqual(ClockFormat.hourMinuteTemplate(uses24Hour: false), "hmm")
+ XCTAssertFalse(ClockFormat.hourMinuteTemplate(uses24Hour: true).contains("j"))
+ XCTAssertFalse(ClockFormat.hourMinuteTemplate(uses24Hour: false).contains("j"))
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CoachSuggestionsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CoachSuggestionsTests.swift
new file mode 100644
index 0000000000..e8b1e7b1db
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CoachSuggestionsTests.swift
@@ -0,0 +1,156 @@
+import Foundation
+import XCTest
+@testable import StrandAnalytics
+import WhoopStore
+
+/// Byte-identity pin for the contextual Coach suggestion chips. The same inputs MUST produce the
+/// same chip strings as the Android twin `com.noop.analytics.CoachSuggestionsTest`. Cross-platform
+/// parity is the contract; if you change a chip string here, change it there in the same PR.
+final class CoachSuggestionsTests: XCTestCase {
+
+ private let fallback = CoachSuggestions.fallback
+
+ /// Build a DailyMetric with only the fields the chip logic reads, everything else nil.
+ private func metric(
+ day: Int, recovery: Double? = nil, hrv: Double? = nil,
+ sleepMin: Double? = nil, strain: Double? = nil
+ ) -> DailyMetric {
+ DailyMetric(
+ day: String(format: "2026-01-%02d", day),
+ totalSleepMin: sleepMin, efficiency: nil, deepMin: nil, remMin: nil, lightMin: nil,
+ disturbances: nil, restingHr: nil, avgHrv: hrv, recovery: recovery, strain: strain,
+ exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil
+ )
+ }
+
+ // MARK: - No-data fallback
+
+ func testNilTodayReturnsFallback() {
+ XCTAssertEqual(CoachSuggestions.suggestions(for: nil, recent: []), fallback)
+ }
+
+ func testAllNilFieldsReturnsFallback() {
+ let today = metric(day: 10) // all four signals nil
+ XCTAssertEqual(CoachSuggestions.suggestions(for: today, recent: []), fallback)
+ }
+
+ // MARK: - Charge bands
+
+ func testLowChargeActiveRecoveryChip() {
+ let today = metric(day: 10, recovery: 20)
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertEqual(chips.first, "Active recovery only today — what should I do?")
+ XCTAssertEqual(chips.last, "Analyse my sleep")
+ XCTAssertTrue(chips.count >= 2 && chips.count <= 4)
+ }
+
+ func testMidChargeQualityOverVolumeChip() {
+ let today = metric(day: 10, recovery: 50)
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertEqual(chips.first, "Quality over volume today — plan my session")
+ }
+
+ func testHighChargeGreenLightChip() {
+ let today = metric(day: 10, recovery: 90)
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertEqual(chips.first, "Green light — how hard can I push today?")
+ }
+
+ func testChargeBoundary34IsMidBand() {
+ let today = metric(day: 10, recovery: 34)
+ XCTAssertEqual(CoachSuggestions.suggestions(for: today, recent: []).first,
+ "Quality over volume today — plan my session")
+ }
+
+ func testChargeBoundary67IsGreenBand() {
+ let today = metric(day: 10, recovery: 67)
+ XCTAssertEqual(CoachSuggestions.suggestions(for: today, recent: []).first,
+ "Green light — how hard can I push today?")
+ }
+
+ // MARK: - HRV trending down
+
+ func testHrvBelowBaselineAddsDownChip() {
+ // 30 days of HRV ~60, then today at 45 (< 0.85 * 60 = 51).
+ let recent = (1...30).map { metric(day: $0, hrv: 60) }
+ let today = metric(day: 31, recovery: 70, hrv: 45)
+ let chips = CoachSuggestions.suggestions(for: today, recent: recent)
+ XCTAssertTrue(chips.contains("Why is my HRV trending down?"))
+ }
+
+ func testHrvAtBaselineDoesNotAddDownChip() {
+ let recent = (1...30).map { metric(day: $0, hrv: 60) }
+ let today = metric(day: 31, recovery: 70, hrv: 60)
+ let chips = CoachSuggestions.suggestions(for: today, recent: recent)
+ XCTAssertFalse(chips.contains("Why is my HRV trending down?"))
+ }
+
+ func testHrvBaselineExcludesToday() {
+ // Today is in `recent` too; it must be excluded from the baseline so a single low day can
+ // still register as "down" against the prior baseline.
+ var recent = (1...30).map { metric(day: $0, hrv: 60) }
+ let today = metric(day: 31, recovery: 70, hrv: 45)
+ recent.append(today)
+ let chips = CoachSuggestions.suggestions(for: today, recent: recent)
+ XCTAssertTrue(chips.contains("Why is my HRV trending down?"))
+ }
+
+ // MARK: - Poor sleep
+
+ func testPoorSleepAddsRecoverChip() {
+ let today = metric(day: 10, recovery: 70, sleepMin: 300) // 5h
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertTrue(chips.contains("I slept poorly — how do I recover today?"))
+ }
+
+ func testSixHoursIsNotPoor() {
+ let today = metric(day: 10, recovery: 70, sleepMin: 360) // exactly 6h
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertFalse(chips.contains("I slept poorly — how do I recover today?"))
+ }
+
+ // MARK: - High strain
+
+ func testHighStrainAddsLoadedChip() {
+ let today = metric(day: 10, recovery: 70, strain: 16)
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertTrue(chips.contains("Have I done enough today, or push more?"))
+ }
+
+ func testStrainBelow14DoesNotAddLoadedChip() {
+ let today = metric(day: 10, recovery: 70, strain: 13)
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertFalse(chips.contains("Have I done enough today, or push more?"))
+ }
+
+ // MARK: - Cap + stable generic
+
+ func testAllSignalsFireCapsAtFour() {
+ let recent = (1...30).map { metric(day: $0, hrv: 60) }
+ let today = metric(day: 31, recovery: 20, hrv: 40, sleepMin: 300, strain: 16)
+ let chips = CoachSuggestions.suggestions(for: today, recent: recent)
+ // charge + hrv + sleep + strain + stable generic = 5 candidates → capped at 4.
+ XCTAssertEqual(chips.count, 4)
+ // The stable generic is appended last; with the cap at 4, it is the 4th element (the 5th
+ // candidate, the strain chip, is dropped). Charge is first.
+ XCTAssertEqual(chips.first, "Active recovery only today — what should I do?")
+ XCTAssertEqual(chips.last, "Analyse my sleep")
+ }
+
+ func testChargeOnlyYieldsChargeAndGeneric() {
+ let today = metric(day: 10, recovery: 90)
+ let chips = CoachSuggestions.suggestions(for: today, recent: [])
+ XCTAssertEqual(chips, ["Green light — how hard can I push today?", "Analyse my sleep"])
+ }
+
+ // MARK: - Byte-identity of the fallback list
+
+ func testFallbackListIsCanonical() {
+ XCTAssertEqual(fallback, [
+ "How's my recovery trending this week?",
+ "What should today's training look like?",
+ "Analyse my sleep",
+ "Why am I run down?",
+ ])
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift
index fac6073f3a..27eb5df6fb 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/ConnectionReadoutTests.swift
@@ -204,9 +204,11 @@ final class ConnectionReadoutTests: XCTestCase {
XCTAssertEqual(
ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 1_782_475_600),
"yes")
+ // #1823: no clock was READ on this path - it is the 5/MG fallback, where the only evidence is
+ // how the strap dated its records. The wording must not claim a clock reading.
XCTAssertEqual(
ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: 40_000_000),
- "no (RTC reads 1970/71)")
+ "no (records dated 1970/71)")
XCTAssertEqual(
ConnectionReadout.clockLatchedLabel(deviceClockUnix: nil, strapNewestUnix: nil),
"no (waiting for the strap clock)")
@@ -224,6 +226,75 @@ final class ConnectionReadoutTests: XCTestCase {
"no signal seen yet must not fabricate a fault")
}
+ /// #1818: the remedy must track the battery. A charged strap told to "charge to 100%" is the bug
+ /// the field report hit - the user had already done it, twice.
+ func testRtcWarningRemedyTracksBattery() {
+ let flat = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil,
+ batteryPct: 40)
+ XCTAssertEqual(flat?.contains("Charge the strap to 100%"), true,
+ "a low strap keeps the charge advice - a flat battery really does reset the RTC")
+
+ let charged = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil,
+ batteryPct: 100)
+ XCTAssertEqual(charged?.contains("Charge the strap to 100%"), false,
+ "an already-charged strap must not be sent round the loop it just ran")
+ XCTAssertEqual(charged?.contains("already charged"), true)
+ XCTAssertEqual(charged?.contains("strap log"), true, "it must name the next actionable step")
+ // The charged copy must stay true for EVERY strap. "NOOP re-sends the clock on every connect"
+ // holds on WHOOP4 but not on a 5/MG, where the write is gated behind didBond and an unbondable
+ // strap (#1635) is never clocked at all - the strap most likely to be showing this warning.
+ XCTAssertEqual(charged?.contains("every connect"), false,
+ "no model-specific mechanism claim in copy shown to every model")
+
+ // Pin the VALUE, not just the symbol: feeding the constant back into the function under test
+ // can never catch a wrong threshold, and nothing else would catch it drifting away from the
+ // Kotlin twin - the two platforms would each keep passing while giving different advice.
+ XCTAssertEqual(ConnectionReadout.rtcAlreadyChargedPct, 95)
+
+ // Boundary, from both sides, with literals: inclusive at 95, charge advice at 94.
+ let atThreshold = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000,
+ strapNewestUnix: nil, batteryPct: 95)
+ XCTAssertEqual(atThreshold?.contains("already charged"), true)
+ let justBelow = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000,
+ strapNewestUnix: nil, batteryPct: 94)
+ XCTAssertEqual(justBelow?.contains("Charge the strap to 100%"), true)
+
+ // Battery not read yet: we only withdraw advice on evidence, so the default stands.
+ let unknown = ConnectionReadout.rtcWarning(deviceClockUnix: 40_000_000, strapNewestUnix: nil)
+ XCTAssertEqual(unknown?.contains("Charge the strap to 100%"), true)
+
+ // A sane clock stays silent no matter how full the battery is.
+ XCTAssertNil(ConnectionReadout.rtcWarning(deviceClockUnix: 1_782_475_600,
+ strapNewestUnix: 1_782_475_000, batteryPct: 100))
+ }
+
+ /// #1809: the epitaph exists so a strap log can STATE that nothing arrived, rather than a reporter
+ /// inferring it from the fact that every logged line happened to be outgoing.
+ func testLinkEpitaph() {
+ let silent = ConnectionReadout.linkEpitaph(upMillis: 4_123, inboundFrames: 0, inboundBytes: 0,
+ cmdChannelFrames: 0, realtimeArmed: false,
+ ended: "CBError.connectionTimeout(6)")
+ XCTAssertEqual(silent,
+ "Link epitaph: up 4123ms, inbound 0 frames / 0 bytes (cmd-channel 0), "
+ + "realtime armed=no, ended=CBError.connectionTimeout(6)"
+ + " - the strap sent NOTHING on this link")
+
+ // A link that carried traffic must NOT claim silence.
+ let alive = ConnectionReadout.linkEpitaph(upMillis: 61_000, inboundFrames: 812,
+ inboundBytes: 40_990, cmdChannelFrames: 9,
+ realtimeArmed: true, ended: "intentional")
+ XCTAssertEqual(alive,
+ "Link epitaph: up 61000ms, inbound 812 frames / 40990 bytes (cmd-channel 9), "
+ + "realtime armed=yes, ended=intentional")
+ XCTAssertFalse(alive.contains("NOTHING"))
+
+ // Negatives are clamped rather than printed: a monotonic-clock hiccup must not emit "up -3ms".
+ XCTAssertTrue(ConnectionReadout.linkEpitaph(upMillis: -3, inboundFrames: -1, inboundBytes: -9,
+ cmdChannelFrames: -2, realtimeArmed: false,
+ ended: "x")
+ .hasPrefix("Link epitaph: up 0ms, inbound 0 frames / 0 bytes (cmd-channel 0)"))
+ }
+
func testLastFrameLabel() {
XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: 990, nowUnix: 1_002), "12s ago")
XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: nil, nowUnix: 1_002), "no frames yet")
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift
index 05a0691d3d..3fa2d5e5c5 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCaloriesTests.swift
@@ -8,8 +8,8 @@ import WhoopProtocol
/// DayCaloriesTest vectors value-for-value.
final class DayCaloriesTests: XCTestCase {
- private func hrDay(bpm: Int, n: Int) -> [HRSample] {
- (0.. [HRSample] {
+ (0..=` quietly weakened to `>`
+ /// would have moved that boundary a full day and nothing would have failed.
+ func testFallbackTakesTheMidnightExactlyEighteenHoursAfterOnset() {
+ // 06:00 + 18 h lands exactly on the next midnight: taken directly, not rolled past.
+ XCTAssertEqual(DayCycleResolver.fallbackMidnight(after: 6 * 3_600, offsetSec: 0), 86_400)
+ }
+
+ /// The rolling branch at a different onset from the case above, so the two are not the same test twice.
+ func testFallbackRollsWhenTheNextMidnightIsTooSoon() {
+ // 23:00 + 18 h overshoots the next midnight, so the one after it wins.
+ XCTAssertEqual(DayCycleResolver.fallbackMidnight(after: 23 * 3_600, offsetSec: 0), 2 * 86_400)
+ }
+
+ /// The boundary is LOCAL midnight, not UTC midnight — and until now nothing said so. Every
+ /// day-cycle case on both platforms passed a zero offset, so the whole offset arithmetic, which is
+ /// the part that decides which local day a boundary lands on, was unpinned. This repo has already
+ /// had days re-bucket on travel once, so it is worth a case rather than an argument.
+ ///
+ /// 06:00 local at UTC-5 is 11:00 UTC. Plus 18 h is 05:00 UTC the next day, which IS local midnight
+ /// there, so the direct branch takes it: 104_400 = 29 h UTC = 00:00 local. A resolver that floored
+ /// to UTC midnight would answer 86_400 and be a day out for a third of the planet.
+ func testFallbackLandsOnLocalMidnightNotUTC() {
+ let offsetSec = -5 * 3_600
+ let onsetLocal0600 = 6 * 3_600 - offsetSec
+ XCTAssertEqual(DayCycleResolver.fallbackMidnight(after: onsetLocal0600, offsetSec: offsetSec),
+ 104_400)
+ }
+
+ func testAbsoluteCapStillUsesSyntheticMidnight() {
+ let sleep = DayCycleWindow(id: "sleep", startInclusive: 0, endExclusive: 0,
+ displayDay: "1970-01-01", source: .detectedSleep)
+ XCTAssertEqual(DayCycleResolver.activeWindow(mode: .sleepOnset, latestSleep: sleep,
+ now: 40 * 3_600, offsetSec: 0).source,
+ .syntheticMidnight)
+ }
+
+ func testCoverageSegmentsPreferPriorityWithoutCrossingDeviceCounters() {
+ let window = PhysiologicalSteps.CycleWindow(sleepId: "night", onset: 100, endExclusive: 500)
+ let segments = PhysiologicalSteps.ownerSegmentsFromCoverage(window, coverage: [
+ .init(owner: "secondary", onset: 100, endExclusive: 350, priority: 1),
+ .init(owner: "active", onset: 200, endExclusive: 500, priority: 0),
+ ], fallbackOwner: "secondary")
+ XCTAssertEqual(segments, [
+ .init(owner: "secondary", onset: 100, endExclusive: 200),
+ .init(owner: "active", onset: 200, endExclusive: 500),
+ ])
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift
index 42db1a7f80..de3e839b6e 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DetectionFunnelTests.swift
@@ -141,7 +141,11 @@ final class DetectionFunnelTests: XCTestCase {
f.droppedShort = 4; f.droppedNoHR = 0; f.droppedLowIntensity = 0; f.kept = 0
XCTAssertEqual(
WorkoutDetector.detectionFunnelLine(day: "2026-08-24", funnel: f),
- "effort detect day=2026-08-24 hr=34137 motion=34136 restHR=59 floor=74 "
+ // Renamed from "effort detect": this funnel is WORKOUT-BOUT detection, and sitting under
+ // the Effort name made it read as the explanation for the Today ring's number. It is not —
+ // StrainScorer.strain computes that from HR alone, with no motion input — and the confusion
+ // has already produced one wrong diagnosis from a real log. `effort score` is the ring's line.
+ "workout detect day=2026-08-24 hr=34137 motion=34136 restHR=59 floor=74 "
+ "motionOK=1203 hrMissing=12 hrTooLow=1103 active=88 runs=6 bridged=4 "
+ "longestRunS=212 meanRunS=74 "
+ "short=4 noHR=0 lowIntensity=0 kept=0")
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift
index c3c3d63ce0..7c704ae5d5 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectRankerTests.swift
@@ -38,6 +38,7 @@ final class EffectRankerTests: XCTestCase {
}
let out = EffectRanker.rank(behaviors: ["Alcohol": behaviorDays],
+ controls: ["Alcohol": Set(outcome.keys).subtracting(behaviorDays)],
outcomeByDay: outcome, outcome: "Charge")
let r = row(out, "Alcohol")
XCTAssertNotNil(r)
@@ -62,7 +63,7 @@ final class EffectRankerTests: XCTestCase {
private func effectAtLag(_ behaviorDays: Set, _ outcome: [String: Double],
_ lag: Int) -> BehaviorEffect? {
let shifted = EffectRanker.shiftedOutcome(outcome, byLag: lag)
- return BehaviorInsights.effect(behaviorDays: behaviorDays, outcomeByDay: shifted,
+ return BehaviorInsights.effect(behaviorDays: behaviorDays, controlDays: Set(shifted.keys).subtracting(behaviorDays), outcomeByDay: shifted,
behavior: "Alcohol", outcome: "Charge")
}
@@ -82,6 +83,7 @@ final class EffectRankerTests: XCTestCase {
for d in 1...8 { outcome[ymd(2026, 7, d)] = 70 + jitter(d) } // plenty of "without"
let out = EffectRanker.rank(behaviors: ["Sparse": thin],
+ controls: ["Sparse": Set(outcome.keys).subtracting(thin)],
outcomeByDay: outcome, outcome: "Charge")
XCTAssertTrue(out.isEmpty)
}
@@ -110,6 +112,8 @@ final class EffectRankerTests: XCTestCase {
for d in 10...20 { outcome[ymd(2026, 5, d)] = 70 + jitter(d) }
let out = EffectRanker.rank(behaviors: ["Big": big, "Small": small],
+ controls: ["Big": Set(outcome.keys).subtracting(big),
+ "Small": Set(outcome.keys).subtracting(small)],
outcomeByDay: outcome, outcome: "Charge")
XCTAssertEqual(out.map { $0.behavior }, ["Big", "Small"]) // |d| Big > Small
XCTAssertEqual(row(out, "Big")!.lag, 0) // both are same-day effects
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift
index 01116e9626..3981684681 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffectiveEffortTests.swift
@@ -47,4 +47,61 @@ final class EffectiveEffortTests: XCTestCase {
func testEqualSourcesResolveToThatValue() {
XCTAssertEqual(StrainScorer.effectiveEffort(live: 7.25, stored: 7.25)!, 7.25, accuracy: 1e-9)
}
+
+ /// #37: when both sources are zero, every sign pairing has the canonical positive-zero bits.
+ func testBothPresentZerosCanonicalizePositiveZero() {
+ let cases: [(label: String, live: Double, stored: Double)] = [
+ ("positive/positive", 0.0, 0.0),
+ ("positive/negative", 0.0, -0.0),
+ ("negative/positive", -0.0, 0.0),
+ ("negative/negative", -0.0, -0.0),
+ ]
+
+ for testCase in cases {
+ let result = StrainScorer.effectiveEffort(live: testCase.live, stored: testCase.stored)!
+ XCTAssertEqual(result.bitPattern, 0.0.bitPattern, testCase.label)
+ }
+ }
+
+ /// A missing source is passthrough, including its exact signed-zero and NaN representation.
+ func testSingleSourcePassesThroughBitForBit() {
+ let values = [
+ 0.0,
+ -0.0,
+ 7.25,
+ -7.25,
+ Double.infinity,
+ -Double.infinity,
+ Double(bitPattern: 0x7ff8_0000_0000_0042),
+ ]
+
+ for value in values {
+ XCTAssertEqual(StrainScorer.effectiveEffort(live: value, stored: nil)!.bitPattern,
+ value.bitPattern)
+ XCTAssertEqual(StrainScorer.effectiveEffort(live: nil, stored: value)!.bitPattern,
+ value.bitPattern)
+ }
+ }
+
+ /// The zero canonicalization must not broaden into a replacement for Swift's MAX semantics.
+ func testBothPresentNonzeroAndNaNBehaviorIsUnchanged() {
+ let nanA = Double(bitPattern: 0x7ff8_0000_0000_0042)
+ let nanB = Double(bitPattern: 0x7ff8_0000_0000_0024)
+ let cases: [(live: Double, stored: Double, expected: Double)] = [
+ (2.3, 0.5, 2.3),
+ (7.25, 7.25, 7.25),
+ (Double.infinity, 12.0, Double.infinity),
+ (12.0, Double.infinity, Double.infinity),
+ (-Double.infinity, -Double.infinity, -Double.infinity),
+ (nanA, 1.0, nanA),
+ (1.0, nanA, 1.0),
+ (nanA, nanB, nanA),
+ (nanB, nanA, nanB),
+ ]
+
+ for testCase in cases {
+ let result = StrainScorer.effectiveEffort(live: testCase.live, stored: testCase.stored)!
+ XCTAssertEqual(result.bitPattern, testCase.expected.bitPattern)
+ }
+ }
}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffortScoreFunnelTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffortScoreFunnelTests.swift
new file mode 100644
index 0000000000..e40e20bb5a
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EffortScoreFunnelTests.swift
@@ -0,0 +1,44 @@
+import XCTest
+import WhoopProtocol
+@testable import StrandAnalytics
+
+/// The Effort score's funnel line — the trace `StrainScorer` had none of.
+///
+/// Twin of the Kotlin `EffortScoreFunnelTest`. The expected strings are the Kotlin ones: these two lines
+/// exist so an Android and an Apple log of the same day compare directly, so asserting each side against
+/// itself would prove only that each is self-consistent.
+final class EffortScoreFunnelTests: XCTestCase {
+
+ func testACalmDayReportsTheZeroItMeasured() {
+ XCTAssertEqual(
+ StrainScorer.scoreFunnelLine(day: "2026-08-31", hrSamples: 39339, enough: true,
+ maxHR: 185.0, maxHRProvided: true, restingHR: 58.0,
+ method: .edwards, trimp: 0.0, strain: 0.0),
+ "effort score day=2026-08-31 hr=39339 enough=true hrMax=185.0(provided) rhr=58.0"
+ + " reserve=127.0 method=edwards trimp=0.0 strain=0.0"
+ )
+ }
+
+ /// The distinction the ring cannot show. A refusal and a genuine zero both render as "0"; only the
+ /// line says which happened, and n/a is what makes the refusal legible.
+ func testARefusalIsNotAZero() {
+ XCTAssertEqual(
+ StrainScorer.scoreFunnelLine(day: "2026-08-31", hrSamples: 12, enough: false,
+ maxHR: 185.0, maxHRProvided: false, restingHR: 58.0,
+ method: .edwards, trimp: nil, strain: nil),
+ "effort score day=2026-08-31 hr=12 enough=false hrMax=185.0(default) rhr=58.0"
+ + " reserve=127.0 method=edwards trimp=n/a strain=n/a"
+ )
+ }
+
+ /// The hook must cost nothing when nobody is watching, and must not fire at view refresh rate: unlike
+ /// the Kotlin twin this scorer is memoized because the Today view re-reads it on every live-HR tick.
+ func testNoDiagSinkMeansNoLine() {
+ let hr = (0..<5).map { HRSample(ts: 1_700_000_000 + $0 * 60, bpm: 60) }
+ var emitted: [String] = []
+ XCTAssertNil(StrainScorer.strain(hr))
+ XCTAssertNil(StrainScorer.strain(hr, diag: { emitted.append($0) }, day: "2026-08-31"))
+ XCTAssertEqual(emitted.count, 1)
+ XCTAssertTrue(emitted[0].hasPrefix("effort score day=2026-08-31 "), emitted[0])
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift
index c17a9cb062..45e1ff6361 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVAnalyzerSampleOrdTests.swift
@@ -82,4 +82,27 @@ final class HRVAnalyzerSampleOrdTests: XCTestCase {
XCTAssertEqual(HRVAnalyzer.pct(3, 8), 38) // 37.5 -> 38
XCTAssertEqual(HRVAnalyzer.pct(0, 0), 0)
}
+
+ /// Twin of Kotlin `pctSurvivesAWholeNightOfBeatTime`. `deliveryHistogram` passes a whole night's
+ /// beat-time in milliseconds, so `part * 200` needs more than 32 bits. `Int` is 64-bit here, so this
+ /// passed before the widening as well - it is a parity pin and a guard for `arm64_32`, where
+ /// StrandAnalytics also builds and where the Kotlin failure would reproduce exactly.
+ func testPctSurvivesAWholeNightOfBeatTime() {
+ XCTAssertEqual(HRVAnalyzer.pct(15_264_177, 35_498_088), 43)
+ XCTAssertEqual(HRVAnalyzer.pct(36_296_594, 39_886_368), 91)
+ }
+
+ /// Twin of Kotlin `pctWrapWasNotAlwaysNegative`. The 32-bit wrap did not always produce a NEGATIVE
+ /// percentage, so "is it negative" was never a sound test for an affected capture.
+ ///
+ /// These pin the exact boundary and the readable-wrong case. The bound is `part * 200 + total`
+ /// exceeding `Int32.max`, not `part * 200` alone: 10,683,998 was still computed correctly in 32
+ /// bits, 10,683,999 wrapped to -100, and a 100% multi-share 8 h night wrapped to 25 - positive,
+ /// plausible, and wrong by 75 points. `Int` is 64-bit here, so these pass either way; they are a
+ /// parity pin and a guard for `arm64_32`, where the Kotlin failure would reproduce exactly.
+ func testPctWrapWasNotAlwaysNegative() {
+ XCTAssertEqual(HRVAnalyzer.pct(10_683_998, 10_683_998), 100)
+ XCTAssertEqual(HRVAnalyzer.pct(10_683_999, 10_683_999), 100)
+ XCTAssertEqual(HRVAnalyzer.pct(28_800_000, 28_800_000), 100)
+ }
}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift
index e246db99a9..8a80ed8efe 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HeartRateRecoveryTests.swift
@@ -15,6 +15,15 @@ final class HeartRateRecoveryTests: XCTestCase {
return values.enumerated().map { i, bpm in HRSample(ts: target - values.count / 2 + i, bpm: bpm) }
}
+ private func result(for eligibilitySamples: [HRSample]) -> HeartRateRecovery.Result? {
+ HeartRateRecovery.calculate(
+ samples: eligibilitySamples + window(minutes: 1, values: [140, 140, 140]),
+ workoutStart: end - 300,
+ workoutEnd: end,
+ maxHR: 200
+ )
+ }
+
func testCalculatesOneTwoAndFiveMinuteDropsFromRobustReadings() {
let samples = denseEligible()
+ window(minutes: 1, values: [146, 146, 220, 146, 146]) // optical spike cannot own the reading
@@ -42,6 +51,58 @@ final class HeartRateRecoveryTests: XCTestCase {
workoutEnd: end, maxHR: 200))
}
+ func testRejectsThreeSeparatedFortySecondHighIntensityRuns() {
+ let samples = [
+ HRSample(ts: end - 140, bpm: 170), HRSample(ts: end - 130, bpm: 170),
+ HRSample(ts: end - 120, bpm: 170), HRSample(ts: end - 110, bpm: 170),
+ HRSample(ts: end - 100, bpm: 120),
+ HRSample(ts: end - 90, bpm: 170), HRSample(ts: end - 80, bpm: 170),
+ HRSample(ts: end - 70, bpm: 170), HRSample(ts: end - 60, bpm: 170),
+ HRSample(ts: end - 50, bpm: 120),
+ HRSample(ts: end - 40, bpm: 170), HRSample(ts: end - 30, bpm: 170),
+ HRSample(ts: end - 20, bpm: 170), HRSample(ts: end - 10, bpm: 170),
+ HRSample(ts: end, bpm: 120),
+ ]
+
+ XCTAssertNil(result(for: samples))
+ }
+
+ func testGapExactlyAtCapUsesLeftSampleAndExactMinimumIsAccepted() {
+ let samples = stride(from: end - 120, through: end - 10, by: 10)
+ .map { HRSample(ts: $0, bpm: 170) }
+ + [HRSample(ts: end, bpm: 120)]
+
+ XCTAssertEqual(result(for: samples), .init(endHR: 170, after1Minute: 30,
+ after2Minutes: nil, after5Minutes: nil))
+ }
+
+ func testGapOverCapResetsTheQualifyingRun() {
+ let beforeGap = stride(from: end - 180, through: end - 110, by: 10)
+ .map { HRSample(ts: $0, bpm: 170) }
+ let afterGap = stride(from: end - 99, through: end - 9, by: 10)
+ .map { HRSample(ts: $0, bpm: 170) }
+ + [HRSample(ts: end, bpm: 170)]
+
+ XCTAssertNil(result(for: beforeGap + afterGap))
+ }
+
+ func testBelowThresholdIntervalResetsTheQualifyingRun() {
+ let samples = stride(from: end - 130, through: end, by: 10).map { ts in
+ HRSample(ts: ts, bpm: ts == end - 60 ? 120 : 170)
+ }
+
+ XCTAssertNil(result(for: samples))
+ }
+
+ func testDuplicateTimestampDoesNotBreakAnOtherwiseContinuousRun() {
+ let samples = stride(from: end - 120, through: end, by: 10)
+ .map { HRSample(ts: $0, bpm: 170) }
+ + [HRSample(ts: end - 60, bpm: 120)]
+
+ XCTAssertEqual(result(for: samples), .init(endHR: 170, after1Minute: 30,
+ after2Minutes: nil, after5Minutes: nil))
+ }
+
func testDoesNotCreditPreWorkoutHeartRateTowardEligibility() {
let samples = denseEligible() + window(minutes: 1, values: [140, 140, 140])
// Only the final minute belongs to this workout. The preceding high HR must not make it eligible.
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrOnlyPhysiologyIsolationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrOnlyPhysiologyIsolationTests.swift
new file mode 100644
index 0000000000..fd0178b99e
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrOnlyPhysiologyIsolationTests.swift
@@ -0,0 +1,90 @@
+import Foundation
+import XCTest
+@testable import StrandAnalytics
+
+/// How an HR-only night reaches the day's physiology, guarded at the level it actually fails.
+///
+/// The twin of the Kotlin `HrOnlyPhysiologyIsolationTest`, which had no Apple counterpart until #1884 —
+/// so the decision was pinned on one platform and free to drift on the other, which is precisely the
+/// asymmetry the parity contract exists to prevent.
+///
+/// #1801 made this an EXCLUSION: an HR-only night was kept out of every physiological aggregate. #1884
+/// made it a PREFERENCE, because the exclusion was discarding a computed HRV and leaving Charge with no
+/// input at all. The night is still marked `hrOnly` for anything that wants to weigh it down; what it no
+/// longer gets is a silent delete.
+///
+/// A nil `restingHR`/`avgHRV` only ever protected an aggregate that READS those fields. Two of the day's
+/// physiological aggregates do not: the deep-window HRV pool and the SDNN index both re-derive from `rr`
+/// over each session's own stages, so an HR-only night — which has stages — would be folded into both,
+/// and from there into Charge and the HRV baseline. `deepHrvWindow` is a user setting, so that path is
+/// reachable, not theoretical.
+///
+/// Read from the source because `physiologySessions` is a local inside `analyzeDay` and cannot be
+/// observed directly, and because the failure this guards is a FUTURE aggregate reaching for `matched`
+/// out of habit. A behavioural test on one path would not catch that; this does.
+final class HrOnlyPhysiologyIsolationTests: XCTestCase {
+
+ /// Walk up from this test file to the package root, so the lookup survives whatever directory the
+ /// test runner happens to start in.
+ private func engineSource() throws -> String {
+ var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
+ for _ in 0..<5 {
+ let f = dir.appendingPathComponent("Sources/StrandAnalytics/AnalyticsEngine.swift")
+ if FileManager.default.fileExists(atPath: f.path) {
+ return try String(contentsOf: f, encoding: .utf8)
+ }
+ dir = dir.deletingLastPathComponent()
+ }
+ XCTFail("AnalyticsEngine.swift not found — this test must not pass by default")
+ throw CocoaError(.fileNoSuchFile)
+ }
+
+ /// The slice of `analyzeDay` running from just after the physiology set is named through the end of
+ /// the SDNN call, which is the last of the four aggregates built from it.
+ private func analyzeDayBody() throws -> String {
+ let src = try engineSource()
+ let decl = try XCTUnwrap(src.range(of: "let physiologySessions"),
+ "analyzeDay must name the physiology-session set")
+ // Start AFTER the declaration: those lines read `matched` legitimately, since defining the
+ // preferred set is the one place the unfiltered one belongs.
+ let from = try XCTUnwrap(src.range(of: "\n", range: decl.upperBound.. Bool {
+ !SleepStager.sessionHrvWindows(start: start, end: end, rr: rr, stages: []).compactMap { $0.rmssd }.isEmpty
+ }
+
+ /// Two near-equal beats per second: the shape a same-second collapse WOULD bring back under the
+ /// ceiling. It must still be withheld, and this is the case that guards the gate's cheap
+ /// classification — `sessionAvgHRV` passes coverage as its own collapsed figure to avoid a sort, so a
+ /// night of this shape reports `crossSecondOverCount` internally. The outcome is what matters and is
+ /// asserted here; restoring the real collapsed figure would relabel this night and change nothing.
+ func testSameSecondOverCountIsWithheld() {
+ let start = 1_000, end = 1_600
+ var rr: [RRInterval] = []
+ for i in 0..<600 {
+ rr.append(RRInterval(ts: start + i, rrMs: 900))
+ rr.append(RRInterval(ts: start + i, rrMs: 905))
+ }
+ XCTAssertTrue(windowsYieldRMSSD(start, end, rr), "precondition: these beats DO yield an RMSSD")
+ XCTAssertNil(SleepStager.sessionAvgHRV(start: start, end: end, rr: rr),
+ "an over-counted night must report no HRV")
+ }
+
+ /// Two beats per second far enough apart that a same-second collapse cannot reach them.
+ func testCrossSecondOverCountIsWithheld() {
+ let start = 1_000, end = 1_600
+ var rr: [RRInterval] = []
+ for i in 0..<600 {
+ rr.append(RRInterval(ts: start + i, rrMs: 880))
+ rr.append(RRInterval(ts: start + i, rrMs: 960))
+ }
+ XCTAssertTrue(windowsYieldRMSSD(start, end, rr), "precondition: these beats DO yield an RMSSD")
+ XCTAssertNil(SleepStager.sessionAvgHRV(start: start, end: end, rr: rr),
+ "an over-counted night must report no HRV")
+ }
+
+ /// The gate must not touch an ordinary night: one beat per second, coverage ~1.0.
+ func testAPlausibleNightStillReportsItsHRV() {
+ let start = 1_000, end = 1_600
+ let rr = (0..<600).map { RRInterval(ts: start + $0, rrMs: $0 % 2 == 0 ? 980 : 1_020) }
+ let hrv = SleepStager.sessionAvgHRV(start: start, end: end, rr: rr)
+ XCTAssertNotNil(hrv, "a plausible night must keep its HRV")
+ XCTAssertGreaterThan(hrv ?? 0, 0, "and it must be a real reading, not zero")
+ }
+
+ /// A sparse night is underCovered, which is honest data and stays trusted.
+ func testAnUnderCoveredNightIsNotGated() {
+ let start = 1_000, end = 1_600
+ let rr = (0..<300).map { RRInterval(ts: start + $0 * 2, rrMs: $0 % 2 == 0 ? 980 : 1_020) }
+ XCTAssertNotNil(SleepStager.sessionAvgHRV(start: start, end: end, rr: rr),
+ "sparse is not the same as over-counted")
+ }
+
+ /// The verdict mapping itself, so the seam above and the rule stay pinned separately.
+ func testOnlyTheTwoOverCountVerdictsRefuse() {
+ XCTAssertFalse(HRVAnalyzer.successiveDiffIsTrustworthy(.sameSecondOverCount))
+ XCTAssertFalse(HRVAnalyzer.successiveDiffIsTrustworthy(.crossSecondOverCount))
+ XCTAssertTrue(HRVAnalyzer.successiveDiffIsTrustworthy(.plausible))
+ XCTAssertTrue(HRVAnalyzer.successiveDiffIsTrustworthy(.underCovered))
+ XCTAssertTrue(HRVAnalyzer.successiveDiffIsTrustworthy(.unmeasurable))
+ }
+
+ /// It refuses exactly what the SDNN gate refuses: same rule, stated twice on purpose, never drifting.
+ func testItRefusesTheSameVerdictsAsTheSdnnGate() {
+ let all: [HRVAnalyzer.RrCoverageVerdict] =
+ [.plausible, .underCovered, .sameSecondOverCount, .crossSecondOverCount, .unmeasurable]
+ for v in all {
+ XCTAssertEqual(HRVAnalyzer.beatSpreadIsTrustworthy(v),
+ HRVAnalyzer.successiveDiffIsTrustworthy(v),
+ "verdict \(v) must be judged alike by both gates")
+ }
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LinkBankedSummaryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LinkBankedSummaryTests.swift
new file mode 100644
index 0000000000..7a806e850d
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LinkBankedSummaryTests.swift
@@ -0,0 +1,61 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// #1635: the per-link banked line, split by PATH. Byte-identical twin of the Kotlin
+/// `LinkBankedSummaryTest`, including the exact sentences.
+///
+/// The split is by path rather than by stream because the realtime decoder yields only
+/// hr/rr/events/battery — gravity, respiratory, skin temperature, SpO2 and steps arrive solely through
+/// the offload. An earlier live-only version named those five as empty on EVERY link, bonded or not.
+final class LinkBankedSummaryTests: XCTestCase {
+
+ private func line(liveHr: Int = 0, liveRr: Int = 0, chunks: Int = 0, oHr: Int = 0, oRr: Int = 0,
+ oGrav: Int = 0, oResp: Int = 0, oSkin: Int = 0, oSpo2: Int = 0,
+ oSteps: Int? = 0) -> String {
+ ConnectionReadout.linkBankedSummary(
+ liveHr: liveHr, liveRr: liveRr, offloadChunks: chunks,
+ offloadHr: oHr, offloadRr: oRr, offloadGravity: oGrav,
+ offloadResp: oResp, offloadSkinTemp: oSkin, offloadSpo2: oSpo2, offloadSteps: oSteps)
+ }
+
+ func testAnUnbondedStrapReadsLiveTrafficWithAnOffloadThatNeverRan() {
+ XCTAssertEqual(
+ line(liveHr: 12, liveRr: 7, chunks: 0),
+ "banked this link: live hr=12 rr=7 | offload none")
+ }
+
+ func testAHealthySyncReadsCompletelyDifferently() {
+ let healthy = line(liveHr: 3, liveRr: 2, chunks: 9, oHr: 1200, oRr: 2400, oGrav: 8000,
+ oResp: 8000, oSkin: 8000, oSpo2: 8000, oSteps: 40)
+ XCTAssertEqual(healthy,
+ "banked this link: live hr=3 rr=2 | offload hr=1200 rr=2400 gravity=8000 resp=8000"
+ + " skinTemp=8000 spo2=8000 steps=40")
+ XCTAssertFalse(healthy.contains("nothing banked"))
+ }
+
+ func testAPartialOffloadNamesOnlyTheStreamsThatStayedEmpty() {
+ let l = line(liveHr: 1, chunks: 4, oHr: 500, oRr: 900, oGrav: 0, oResp: 0, oSkin: 700, oSpo2: 700)
+ XCTAssertTrue(l.contains("nothing banked from the offload for: gravity, resp, steps"))
+ }
+
+ func testAStreamThisPlatformCannotMeasureIsOmittedNotZero() {
+ XCTAssertFalse(line(liveHr: 5, chunks: 1, oHr: 10, oSteps: nil).contains("steps"))
+ }
+
+ func testBatteryNeverAppearsOnEitherPath() {
+ XCTAssertFalse(line(liveHr: 9, chunks: 1, oHr: 9).contains("battery"))
+ }
+
+ func testNegativeCountsCannotLeakIntoADiagnostic() {
+ let l = line(liveHr: -5, liveRr: 1, chunks: 2, oHr: -3, oGrav: 4)
+ XCTAssertTrue(l.contains("live hr=0 rr=1"))
+ XCTAssertFalse(l.contains("-3"))
+ }
+
+ /// Twin of the Kotlin case: "never ran" and "ran with nothing new" are different findings.
+ func testAnOffloadThatRanWithNothingNewIsADifferentFinding() {
+ XCTAssertEqual(line(liveHr: 1, liveRr: 1, chunks: 6),
+ "banked this link: live hr=1 rr=1 | offload ran 6 chunk(s), no new rows")
+ XCTAssertTrue(line(liveHr: 1, liveRr: 1, chunks: 0).contains("offload none"))
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift
new file mode 100644
index 0000000000..335577cb8a
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5NoGravityRestChainTests.swift
@@ -0,0 +1,141 @@
+import XCTest
+@testable import StrandAnalytics
+import WhoopProtocol
+import WhoopStore
+
+/// The no-gravity Rest chain, from bug #977 (iOS, WHOOP 5.0, live Bluetooth): "Rest score stuck 93 since
+/// forever."
+///
+/// Renamed from `Live5RestFrozenTests`. The frozen state is closed (see below), and a file called
+/// "Frozen" is the same stale signal the `XCTExpectFailure` in it used to be: something a reader greps,
+/// believes, and acts on long after it stopped being true. #977 stays named here for archaeology; the
+/// file is named for the chain it pins.
+///
+/// The mechanism: a LIVE WHOOP 5.0 streams standard 0x2A37 HR continuously, but gravity is only ever
+/// populated by the *history offload* (Backfiller). With no overnight gravity the day reaches the scoring
+/// loop with dense HR — it clears IntelligenceEngine's `hr.count >= 200` gate, so recovery/Charge still
+/// score — while sleep detection finds nothing. No sleep means no `totalSleepMin`/`efficiency`, so
+/// `AnalyticsEngine.Rest.composite(daily:)` is nil, so NO `sleep_performance` point is written, and the
+/// Rest read-out has nothing new to show.
+///
+/// WHAT CHANGED SINCE THESE TESTS WERE WRITTEN, and why they are repinned rather than deleted:
+///
+/// • The V1 stager still bails without gravity (`grav.count < 2`). That is unchanged and still pinned
+/// below — V1 is a selectable fallback (the 4.0 is unvalidated on either stager, #271/#319), so its
+/// behaviour is worth stating, but it is no longer what a default install runs.
+/// • The rescue is NOT inside `detectSleep` — neither stager finds a night without motion, and both are
+/// pinned saying so below. It is in `IntelligenceEngine`: when a day has no motion, no hypnogram and
+/// no stored night, it falls back to `SleepStager.hrOnlySessions(hr:rr:resp:)` (#1801) and feeds the
+/// result in as `providedSleep`. #1884 then stopped an all-HR-only night being discarded from the
+/// daily aggregates. So the shipping path DOES score a no-gravity night, which is precisely the
+/// "HR-only fallback composite" the old fix-contract test named as one of two acceptable fixes.
+/// That fallback is covered by `SleepStagerHrOnlySessionsTests` + `AnalyticsEngineHrOnlyDayTests`.
+///
+/// The old contract test asserted `Rest.composite(daily:)` on a hand-built row with no sleep fields and
+/// wrapped it in `XCTExpectFailure`. That could never pass, whatever got fixed: a row with no aggregates
+/// is nil BY DEFINITION, not by bug. It was testing the wrong layer. The chain is pinned at its two real
+/// joints instead — does the shipping stager stage the night, and does a staged night yield a composite.
+///
+/// The display half (whether Today shows a stale carried value or falls through to its no-data state) is
+/// #977's `freshRestScore` + `isCarryStale`, which live in the app target and are covered there and by
+/// Android's `RestFreshnessTest`. This package pins only what it owns, and says so at the bottom of the
+/// file rather than keeping a test that re-simulates a rule it cannot import.
+final class Live5NoGravityRestChainTests: XCTestCase {
+
+ private func hrStream(start: Int, durationS: Int, bpm: Int) -> [HRSample] {
+ stride(from: 0, to: durationS, by: 1).map { HRSample(ts: start + $0, bpm: bpm) }
+ }
+
+ /// A late-night start (02:00 UTC, tzOffset 0) so the window is unambiguously overnight and
+ /// the daytime false-sleep guard is irrelevant to the outcome.
+ private func nightStart() -> Int {
+ let refMidnight = 1_749_513_600 // 2026-06-10 00:00:00 UTC
+ return refMidnight + 2 * 3_600
+ }
+
+ // MARK: - Stage 1: no gravity ⇒ no sleep session (the direct BLE-live-5.0 shape)
+
+ /// V1, stated EXPLICITLY rather than taken from the default: 8 h of continuous sleep-plausible HR
+ /// with zero gravity yields nothing, because V1's spine is motion stillness and there is none.
+ ///
+ /// `SleepStagerTests.testDetectSleepEmptyGravity` already asserts the empty-gravity case once, on the
+ /// default stager. The pair here is not that assertion again: it is the statement that the answer is
+ /// the SAME on both stagers, which is the fact the chain turns on and the one whose absence let this
+ /// file spend two years implying V2 might rescue the night. The original spelling relied on
+ /// `useSleepStagerV2`'s default, so it silently stopped describing shipped behaviour when that
+ /// default moved — which is how the implication survived.
+ func testV1WithNoGravityDetectsNoSleep() {
+ let start = nightStart()
+ let dur = 8 * 60 * 60
+ let hr = hrStream(start: start, durationS: dur, bpm: 50)
+ let sessions = SleepStager.detectSleep(hr: hr, gravity: [], useSleepStagerV2: false)
+ XCTAssertTrue(sessions.isEmpty,
+ "V1's spine is motion stillness: no gravity, no session")
+ }
+
+ /// V2 too. This is the correction to my first attempt at this test, which asserted that V2 WOULD
+ /// stage here and failed in CI — rightly.
+ ///
+ /// `detectSleep` has no HR-only rescue on either stager: V2's extra machinery is in how it stages a
+ /// run, not in finding one without motion. The rescue lives one layer up. `IntelligenceEngine` sees
+ /// the empty result and, when there is no motion, no hypnogram and no stored night, falls back to
+ /// `SleepStager.hrOnlySessions(hr:rr:resp:)` and feeds the result in as `providedSleep`
+ /// (`IntelligenceEngine.swift`, gate line `no-motion-no-hypnogram`). That fallback is covered by
+ /// `SleepStagerHrOnlySessionsTests` and `AnalyticsEngineHrOnlyDayTests`, so it is named here rather
+ /// than duplicated.
+ ///
+ /// Pinning this keeps the two facts adjacent: detection genuinely yields nothing without motion, and
+ /// that is not the end of the story — which is exactly what the original version of this file got
+ /// wrong in the other direction, by treating the empty result as the final word on Rest.
+ func testV2WithNoGravityAlsoDetectsNoSleep() {
+ let start = nightStart()
+ let hr = hrStream(start: start, durationS: 8 * 60 * 60, bpm: 50)
+ let sessions = SleepStager.detectSleep(hr: hr, gravity: [], useSleepStagerV2: true)
+ XCTAssertTrue(sessions.isEmpty,
+ "detectSleep needs motion on either stager; the HR-only rescue is the engine's fallback")
+ }
+
+ // MARK: - Stage 2: no sleep ⇒ no Rest composite ⇒ no sleep_performance point
+
+ /// A DailyMetric shaped exactly as `analyzeDay` leaves it when `matched` is empty: HRV/RHR
+ /// present (so Charge can still be scored) but no sleep aggregates. This is the row a live-5.0
+ /// day produces when gravity never offloaded.
+ private func chargeableButUnsleptDaily(day: String) -> DailyMetric {
+ DailyMetric(day: day,
+ totalSleepMin: nil, // absent ⇒ no Rest composite
+ efficiency: nil, // absent ⇒ no Rest composite
+ deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil,
+ restingHr: 52, // present ⇒ recovery/Charge advances
+ avgHrv: 65, // present ⇒ recovery/Charge advances
+ recovery: nil, strain: nil, exerciseCount: nil,
+ spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil)
+ }
+
+ func testUnsleptDailyProducesNilRestComposite() {
+ let daily = chargeableButUnsleptDaily(day: "2026-07-02")
+ // This is the exact guard at AnalyticsEngine.Rest.composite(daily:) line 696:
+ // guard let tstMin = d.totalSleepMin, tstMin > 0, let eff = d.efficiency else { return nil }
+ XCTAssertNil(AnalyticsEngine.Rest.composite(daily: daily),
+ "No sleep aggregates ⇒ Rest.composite(daily:) is nil ⇒ no sleep_performance point written")
+ }
+
+ // MARK: - Stage 3: the display-side freeze this produces
+
+ /// Reproduces the Today resolver's tail fallback (iOS LiquidTodayView.swift line 777 /
+ /// Android TodayScreen.kt line 689): when today has no `sleep_performance` row, both
+ /// platforms fall back to the latest value in the series. If new nights never write a row,
+ /// that latest value is pinned to the last night that WAS scored — 93 — forever.
+ // The downstream joint - a STAGED night yields a composite - deliberately has no test here either.
+ // `Rest.composite` is exercised in `RestWiringImpactTests`, `RestSubScoreTraceTests`,
+ // `SleepStageTotalsTests` and `AnalyticsEngineTests`; a fifth assertion that a complete row scores
+ // would be noise. What is NOT covered elsewhere is the nil case above, which is the one this chain
+ // turns on, so that is the one that lives here.
+
+ // The display half deliberately has NO test here. It used to, and that test asserted `max(by:)` over a
+ // literal dictionary — it exercised no product code at all, which is worse than no test because it
+ // reads like coverage. Since #977 the rule it meant to describe is `freshRestScore` + `isCarryStale`:
+ // today's own value wins, the carry applies only on today and only inside the freshness window, and
+ // otherwise the read-out falls through to its no-data state instead of freezing on a weeks-old number.
+ // Those live in the app target and in Android's `TodayScoring.freshRestScore`, covered by
+ // `RestFreshnessTest`. This package pins what it owns and points at the rest.
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift
deleted file mode 100644
index 31131e97d9..0000000000
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Live5RestFrozenTests.swift
+++ /dev/null
@@ -1,115 +0,0 @@
-import XCTest
-@testable import StrandAnalytics
-import WhoopProtocol
-import WhoopStore
-
-/// Bug #977 (iOS, WHOOP 5.0, live Bluetooth): "Rest score stuck 93 since forever."
-///
-/// Root cause under test: a LIVE WHOOP 5.0 streams standard 0x2A37 HR continuously, but
-/// the accelerometer / gravity stream is only ever populated by the *history offload*
-/// (Backfiller). When the overnight gravity has not been offloaded/decoded, the day reaches
-/// the scoring loop with dense HR (so it clears IntelligenceEngine's `hr.count >= 200` gate
-/// and recovery/Charge can still be computed from HR/HRV), but `SleepStager.detectSleep`
-/// bails at `grav.count < 2 { return [] }` — so no sleep is matched, the DailyMetric carries
-/// no `totalSleepMin`/`efficiency`, and `AnalyticsEngine.Rest.composite(daily:)` returns nil.
-///
-/// A nil composite means NO `sleep_performance` point is written for that night, so the Today
-/// display falls back to the tail of the series (iOS `restSeries.last`, Android
-/// `byDay.entries.maxByOrNull`) — the last night that WAS scored, e.g. 93 — and Rest is frozen
-/// there forever while Charge keeps advancing.
-///
-/// These tests pin the mechanism. They are written to FAIL against a fix that makes a live-5.0
-/// night produce SOME advancing Rest signal (whether by an HR-only fallback composite or by
-/// forcing a gravity offload before scoring). Today they document the frozen state.
-final class Live5RestFrozenTests: XCTestCase {
-
- private func hrStream(start: Int, durationS: Int, bpm: Int) -> [HRSample] {
- stride(from: 0, to: durationS, by: 1).map { HRSample(ts: start + $0, bpm: bpm) }
- }
-
- /// A late-night start (02:00 UTC, tzOffset 0) so the window is unambiguously overnight and
- /// the daytime false-sleep guard is irrelevant to the outcome.
- private func nightStart() -> Int {
- let refMidnight = 1_749_513_600 // 2026-06-10 00:00:00 UTC
- return refMidnight + 2 * 3_600
- }
-
- // MARK: - Stage 1: no gravity ⇒ no sleep session (the direct BLE-live-5.0 shape)
-
- func testLive5NoGravityDenseHRDetectsNoSleep() {
- // 8 h of continuous, sleep-plausible HR (a real live-5.0 night streamed over 0x2A37),
- // but ZERO gravity because the accelerometer offload hasn't landed. detectSleep must
- // return no sessions — the strap streamed HR but not motion.
- let start = nightStart()
- let dur = 8 * 60 * 60
- let hr = hrStream(start: start, durationS: dur, bpm: 50)
- let sessions = SleepStager.detectSleep(hr: hr, gravity: [])
- XCTAssertTrue(sessions.isEmpty,
- "A live-5.0 night with HR but no offloaded gravity yields no sleep session")
- }
-
- // MARK: - Stage 2: no sleep ⇒ no Rest composite ⇒ no sleep_performance point
-
- /// A DailyMetric shaped exactly as `analyzeDay` leaves it when `matched` is empty: HRV/RHR
- /// present (so Charge can still be scored) but no sleep aggregates. This is the row a live-5.0
- /// day produces when gravity never offloaded.
- private func chargeableButUnsleptDaily(day: String) -> DailyMetric {
- DailyMetric(day: day,
- totalSleepMin: nil, // absent ⇒ no Rest composite
- efficiency: nil, // absent ⇒ no Rest composite
- deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil,
- restingHr: 52, // present ⇒ recovery/Charge advances
- avgHrv: 65, // present ⇒ recovery/Charge advances
- recovery: nil, strain: nil, exerciseCount: nil,
- spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil)
- }
-
- func testUnsleptDailyProducesNilRestComposite() {
- let daily = chargeableButUnsleptDaily(day: "2026-07-02")
- // This is the exact guard at AnalyticsEngine.Rest.composite(daily:) line 696:
- // guard let tstMin = d.totalSleepMin, tstMin > 0, let eff = d.efficiency else { return nil }
- XCTAssertNil(AnalyticsEngine.Rest.composite(daily: daily),
- "No sleep aggregates ⇒ Rest.composite(daily:) is nil ⇒ no sleep_performance point written")
- }
-
- // MARK: - Stage 3: the display-side freeze this produces
-
- /// Reproduces the Today resolver's tail fallback (iOS LiquidTodayView.swift line 777 /
- /// Android TodayScreen.kt line 689): when today has no `sleep_performance` row, both
- /// platforms fall back to the latest value in the series. If new nights never write a row,
- /// that latest value is pinned to the last night that WAS scored — 93 — forever.
- // MARK: - The FIX CONTRACT (fails today, passes once #977 is fixed)
-
- /// The contract a fix must satisfy: a live-5.0 day that has HRV/RHR (Charge advances) but no
- /// staged sleep must NOT silently produce a nil Rest signal that freezes the display. Whatever
- /// the fix (an HR-only degraded Rest fallback for a chargeable-but-unslept day, OR forcing a
- /// gravity offload before scoring so `matched` is non-empty), the observable requirement is:
- /// a chargeable day yields SOME non-nil Rest signal so Today advances instead of tailing.
- ///
- /// This intentionally FAILS against today's code (Rest.composite(daily:) returns nil for such a
- /// day). Delete the XCTExpectFailure wrapper when the fix lands.
- func testChargeableDayShouldYieldAdvancingRestSignal_FIX() {
- XCTExpectFailure("#977 not yet fixed: a chargeable-but-unslept live-5.0 day yields a nil Rest signal") {
- let daily = chargeableButUnsleptDaily(day: "2026-07-02")
- XCTAssertNotNil(AnalyticsEngine.Rest.composite(daily: daily),
- "A day that can score Charge must also surface SOME Rest signal, not freeze")
- }
- }
-
- func testTodayRestFreezesOnTailFallbackWhenNoNewPointWritten() {
- // sleep_performance series that stopped advancing days ago (last computed night = 93).
- let restByDay: [String: Double] = [
- "2026-06-25": 88,
- "2026-06-26": 91,
- "2026-06-27": 93, // the last night gravity offloaded + scored
- ]
- let seriesTail = restByDay.max(by: { $0.key < $1.key })?.value // == restSeries.last / maxByOrNull
-
- // Several later days ran (Charge advanced) but wrote NO sleep_performance row.
- for today in ["2026-06-28", "2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02"] {
- let restToday = restByDay[today] ?? seriesTail // offset 0 tail fallback
- XCTAssertEqual(restToday, 93,
- "\(today): Today shows the frozen tail (93), never a fresh score")
- }
- }
-}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LivePersistTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LivePersistTraceTests.swift
new file mode 100644
index 0000000000..6c28f59a2f
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LivePersistTraceTests.swift
@@ -0,0 +1,140 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// Byte-identical oracle against the Kotlin `liveInsertFailedLine`. The expected strings are the exact
+/// ones `StalledLinkDiagnosticsTest` pins on the Android side, so a one-sided wording change fails here
+/// rather than drifting apart in the two field logs these are meant to be read beside each other.
+final class LivePersistTraceTests: XCTestCase {
+ func testStandardHRHostReceiptSeparatesAcceptedAndRejectedRows() {
+ XCTAssertEqual(
+ LivePersistTrace.standardHRHostReceivedLine(
+ hostUnixSeconds: 1_750_000_000,
+ acceptedHRRows: 1, acceptedRRRows: 2,
+ rejectedHRRows: 0, rejectedRRRows: 1,
+ pendingHRRows: 4, pendingRRRows: 5
+ ),
+ "standard-hr transport host-received hostUnixSec=1750000000"
+ + " acceptedHRRows=1 acceptedRRRows=2 rejectedHRRows=0 rejectedRRRows=1"
+ + " pendingHRRows=4 pendingRRRows=5"
+ )
+ }
+
+ func testStandardHRFlushSuccessSeparatesOfferedFromActuallyInsertedRows() {
+ XCTAssertEqual(
+ LivePersistTrace.standardHRFlushAttemptLine(
+ reason: .cadence, offeredHRRows: 4, offeredRRRows: 5
+ ),
+ "standard-hr transport flush-attempt reason=cadence offeredHRRows=4 offeredRRRows=5"
+ )
+ XCTAssertEqual(
+ LivePersistTrace.standardHRFlushSucceededLine(
+ reason: .cadence, offeredHRRows: 4, offeredRRRows: 5,
+ insertedHRRows: 1, insertedRRRows: 2
+ ),
+ "standard-hr transport flush-succeeded reason=cadence offeredHRRows=4 offeredRRRows=5"
+ + " insertedHRRows=1 insertedRRRows=2"
+ )
+ }
+
+ func testStandardHRRetryNamesLifecycleReasonAndTotalPendingRows() {
+ XCTAssertEqual(
+ LivePersistTrace.standardHRRebufferedForRetryLine(
+ reason: .disconnect, attemptedHRRows: 1, attemptedRRRows: 2,
+ pendingHRRows: 3, pendingRRRows: 4, consecutiveFailures: 1
+ ),
+ "standard-hr transport rebuffered-for-retry reason=disconnect"
+ + " attemptedHRRows=1 attemptedRRRows=2 pendingHRRows=3 pendingRRRows=4"
+ + " consecutiveFailures=1"
+ )
+ }
+
+ func testStandardHRLifecycleRouteFlushesBackgroundAndTermination() async {
+ var reasons: [LivePersistTrace.StandardHRFlushReason] = []
+
+ await StandardHRLifecycleFlush.run(event: .background) { reasons.append($0) }
+ await StandardHRLifecycleFlush.run(event: .termination) { reasons.append($0) }
+
+ XCTAssertEqual(reasons.map(\.rawValue), ["background", "termination"])
+ }
+
+ /// Two live transports fail independently (#1118), so the line must say which.
+ func testNamesTheFailingTransport() {
+ XCTAssertTrue(LivePersistTrace.liveInsertFailedLine(
+ transport: "live-standard", errorName: "E", message: nil,
+ hrFrames: 1, rrFrames: 1, consecutiveFailures: 1).contains("on live-standard"))
+ XCTAssertTrue(LivePersistTrace.liveInsertFailedLine(
+ transport: "live-realtime", errorName: "E", message: nil,
+ hrFrames: 1, rrFrames: 1, consecutiveFailures: 1).contains("on live-realtime"))
+ }
+
+ /// One failure is the transient the re-buffer absorbs; a run is a store that will not take these rows.
+ /// If both rendered the same the count would be decoration.
+ func testOneFailureReadsTransientAndARunReadsAsNotRecovering() {
+ let once = LivePersistTrace.liveInsertFailedLine(
+ transport: "live-standard", errorName: "SQLiteFullException",
+ message: "database or disk is full", hrFrames: 12, rrFrames: 13, consecutiveFailures: 1)
+ XCTAssertTrue(once.contains("Re-buffered for the next cadence."))
+ XCTAssertFalse(once.contains("consecutive failures"))
+
+ let many = LivePersistTrace.liveInsertFailedLine(
+ transport: "live-standard", errorName: "SQLiteFullException",
+ message: "database or disk is full", hrFrames: 12, rrFrames: 13, consecutiveFailures: 9)
+ XCTAssertTrue(many.contains("9 consecutive failures"))
+ XCTAssertTrue(many.contains("not recovering them."))
+ }
+
+ /// Whole-line equality, not `contains`: this is the assertion that actually holds the two platforms
+ /// together, since a stray space or a moved clause would still satisfy every check above.
+ func testWholeLineMatchesTheKotlinRendering() {
+ XCTAssertEqual(
+ LivePersistTrace.liveInsertFailedLine(
+ transport: "live-standard", errorName: "SQLiteFullException",
+ message: "database or disk is full", hrFrames: 12, rrFrames: 13, consecutiveFailures: 9),
+ "Live persist FAILED on live-standard — SQLiteFullException: database or disk is full"
+ + " (hr=12 rr=13). 9 consecutive failures — these rows are not landing and the re-buffer"
+ + " is not recovering them.")
+ XCTAssertEqual(
+ LivePersistTrace.liveInsertFailedLine(
+ transport: "live-realtime", errorName: "IllegalStateException", message: nil,
+ hrFrames: 0, rrFrames: 4, consecutiveFailures: 1),
+ "Live persist FAILED on live-realtime — IllegalStateException (hr=0 rr=4)."
+ + " Re-buffered for the next cadence.")
+ }
+
+ func testMessageSurvivesAndIsBounded() {
+ let line = LivePersistTrace.liveInsertFailedLine(
+ transport: "live-standard", errorName: "IllegalStateException",
+ message: String(repeating: "x", count: 500), hrFrames: 1, rrFrames: 2, consecutiveFailures: 1)
+ XCTAssertTrue(line.contains(String(repeating: "x", count: 200)))
+ XCTAssertFalse(line.contains(String(repeating: "x", count: 201)))
+ }
+
+ func testBlankOrAbsentMessageLeavesNoDanglingSeparator() {
+ for message in [nil, " "] as [String?] {
+ let line = LivePersistTrace.liveInsertFailedLine(
+ transport: "live-standard", errorName: "IllegalStateException", message: message,
+ hrFrames: 1, rrFrames: 2, consecutiveFailures: 1)
+ XCTAssertFalse(line.contains(": ("), "a blank message must not leave a dangling colon")
+ }
+ }
+
+ // MARK: - rate limit
+
+ /// The first failure is the one most worth having, so "never emitted" must not read as "just emitted".
+ func testFirstFailureAlwaysEmits() {
+ XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 0, nowMs: 1_000))
+ XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: -5, nowMs: 1_000))
+ }
+
+ func testGapIsHonouredAtItsBoundary() {
+ XCTAssertFalse(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 1_000, nowMs: 60_999))
+ XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 1_000, nowMs: 61_000))
+ }
+
+ /// A clock that steps backwards must not latch the line off until real time catches up.
+ func testBackwardsClockEmitsRatherThanLatchingOff() {
+ XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: 10_000, nowMs: 5_000))
+ XCTAssertTrue(LivePersistTrace.shouldEmitLiveInsertFailure(
+ lastEmitMs: Int64.max / 2, nowMs: 1_000))
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LocalDayWindowsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LocalDayWindowsTests.swift
new file mode 100644
index 0000000000..f060d7e64e
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LocalDayWindowsTests.swift
@@ -0,0 +1,434 @@
+import Foundation
+import XCTest
+@testable import StrandAnalytics
+
+/// The day-window primitive, one test per scenario of the `daily-windows` specification, plus the
+/// parity oracle that the Kotlin twin reads from the same committed file.
+///
+/// Every fixture passes an explicit zone and an explicit reference instant, so nothing here depends on
+/// the machine's zone or on when the suite runs. The two exceptions are the default-accessor tests,
+/// which are about the defaults themselves and deliberately do not go through the oracle.
+final class LocalDayWindowsTests: XCTestCase {
+
+ // MARK: - Fixture helpers
+
+ /// A date written the way the specification writes it.
+ private func date(_ text: String) -> LocalCalendarDate {
+ let parts = text.split(separator: "-").map { Int($0)! }
+ return LocalCalendarDate(year: parts[0], month: parts[1], day: parts[2])
+ }
+
+ /// An instant from whole seconds since the epoch.
+ private func instant(_ epochSeconds: Int) -> Date {
+ Date(timeIntervalSince1970: TimeInterval(epochSeconds))
+ }
+
+ /// A UTC instant written as `YYYY-MM-DDTHH:MM:SSZ`, parsed without a formatter so the test's own
+ /// expectations do not depend on a locale or a calendar.
+ private func utc(_ text: String) -> Date {
+ let halves = text.dropLast().split(separator: "T")
+ let day = halves[0].split(separator: "-").map { Int($0)! }
+ let time = halves[1].split(separator: ":").map { Int($0)! }
+ let days = LocalCalendarDate(year: day[0], month: day[1], day: day[2]).daysSinceEpoch
+ return instant(days * 86_400 + time[0] * 3600 + time[1] * 60 + time[2])
+ }
+
+ /// The helper for a zone, with a reference instant that no scenario in this section depends on.
+ private func windows(_ zoneName: String,
+ reference: Date = Date(timeIntervalSince1970: 0)) -> LocalDayWindows {
+ LocalDayWindows(timeZone: TimeZone(identifier: zoneName)!, referenceInstant: reference)
+ }
+
+ // MARK: - A local date begins at its own local midnight
+
+ /// The discriminating case: the helper and the analysis core's fixed-offset arithmetic disagree by
+ /// an hour across a transition.
+ ///
+ /// The core's arithmetic is re-derived inline, verbatim per the scenario, because the incumbent
+ /// lives in the app target and a package test cannot import it: floor to a local midnight using the
+ /// zone's offset at the reference instant, then subtract whole 86,400-second days.
+ func testResolvedStartDiffersFromFixedOffsetArithmeticAcrossATransition() {
+ let zone = TimeZone(identifier: "America/New_York")!
+ let reference = utc("2025-11-10T17:00:00Z") // 12:00 local, inside standard time
+ let helper = LocalDayWindows(timeZone: zone, referenceInstant: reference)
+
+ let resolved = helper.start(of: date("2025-10-19"))
+
+ let offset = TimeInterval(zone.secondsFromGMT(for: reference))
+ let localSeconds = reference.timeIntervalSince1970 + offset
+ let referenceMidnight = (localSeconds / 86_400).rounded(.down) * 86_400 - offset
+ let fixedOffsetStart = Date(timeIntervalSince1970: referenceMidnight - 22 * 86_400)
+
+ XCTAssertEqual(resolved, utc("2025-10-19T04:00:00Z"))
+ XCTAssertEqual(fixedOffsetStart, utc("2025-10-19T05:00:00Z"))
+ XCTAssertNotEqual(resolved, fixedOffsetStart)
+ }
+
+ /// Two reference instants on opposite sides of the zone's autumn transition give the same start.
+ func testResultDoesNotDependOnTheReferenceInstant() {
+ let before = windows("America/New_York", reference: utc("2025-10-15T12:00:00Z"))
+ let after = windows("America/New_York", reference: utc("2025-12-15T12:00:00Z"))
+
+ XCTAssertEqual(before.start(of: date("2025-10-19")), utc("2025-10-19T04:00:00Z"))
+ XCTAssertEqual(after.start(of: date("2025-10-19")), utc("2025-10-19T04:00:00Z"))
+ }
+
+ /// A zone offset of 5 hours 45 minutes puts the start on its own local midnight, off the hour.
+ func testDayWithFortyFiveMinuteOffsetStartsAtItsOwnLocalMidnight() {
+ let helper = windows("Asia/Kathmandu")
+
+ let start = helper.start(of: date("2025-06-15"))
+
+ XCTAssertEqual(start, utc("2025-06-14T18:15:00Z"))
+ XCTAssertEqual(helper.offsetSeconds(at: start!), 20_700)
+ XCTAssertNotEqual(Int(start!.timeIntervalSince1970) % 3600, 0)
+ }
+
+ // MARK: - A day window lasts as long as the zone says and carries its own offset
+
+ /// The spring-forward day is 23 hours long.
+ func testSpringForwardDayIsTwentyThreeHours() {
+ let window = windows("America/New_York").window(of: date("2025-03-09"))
+
+ XCTAssertEqual(window?.duration, 23 * 3600)
+ }
+
+ /// The fall-back day is 25 hours long.
+ func testFallBackDayIsTwentyFiveHours() {
+ let window = windows("America/New_York").window(of: date("2025-11-02"))
+
+ XCTAssertEqual(window?.duration, 25 * 3600)
+ }
+
+ /// A zone without daylight saving has 24-hour days.
+ func testDayInZoneWithoutDaylightSavingIsTwentyFourHours() {
+ let window = windows("Asia/Kolkata").window(of: date("2025-06-15"))
+
+ XCTAssertEqual(window?.duration, 24 * 3600)
+ }
+
+ /// Consecutive windows meet exactly across a transition, leaving neither a gap nor an overlap.
+ func testConsecutiveWindowsMeetExactly() throws {
+ let helper = windows("America/New_York")
+
+ let first = try XCTUnwrap(helper.window(of: date("2025-11-01")))
+ let second = try XCTUnwrap(helper.window(of: date("2025-11-02")))
+ let third = try XCTUnwrap(helper.window(of: date("2025-11-03")))
+
+ XCTAssertEqual(first.nextStart, second.start)
+ XCTAssertEqual(second.nextStart, third.start)
+ }
+
+ /// The window reports the offset in effect at its own start, not at some other moment of the day.
+ func testWindowReportsOffsetInEffectAtItsStart() {
+ let helper = windows("America/New_York")
+
+ XCTAssertEqual(helper.window(of: date("2025-11-01"))?.utcOffsetSeconds, -14_400)
+ XCTAssertEqual(helper.window(of: date("2025-11-03"))?.utcOffsetSeconds, -18_000)
+ }
+
+ // MARK: - The helper resolves the start of the local day containing an instant
+
+ /// An instant inside the repeated local hour still resolves to that day's start.
+ func testInstantInsideTwentyFiveHourDayResolvesToThatDaysStart() {
+ let helper = windows("America/New_York")
+
+ let start = helper.startOfDay(containing: utc("2025-11-02T05:30:00Z"))
+
+ XCTAssertEqual(start, utc("2025-11-02T04:00:00Z"))
+ }
+
+ /// A start instant resolves to itself, for every fixture date that exists.
+ func testContainingDayStartOfAStartInstantIsItself() {
+ for (zone, day) in [("America/New_York", "2025-03-09"),
+ ("America/New_York", "2025-11-02"),
+ ("America/New_York", "2025-10-19"),
+ ("America/Santiago", "2025-09-07"),
+ ("America/Havana", "2025-11-02"),
+ ("Pacific/Apia", "2011-12-31"),
+ ("Asia/Kolkata", "2025-06-15"),
+ ("Asia/Kathmandu", "2025-06-15")] {
+ let helper = windows(zone)
+ let start = helper.start(of: date(day))
+ XCTAssertNotNil(start, "\(zone) \(day) must have a start")
+ if let start {
+ XCTAssertEqual(helper.startOfDay(containing: start), start, "\(zone) \(day)")
+ }
+ }
+ }
+
+ // MARK: - An instant's day key names the window that contains it
+
+ /// The key flips exactly at the window boundary and nowhere else.
+ func testDayKeyChangesExactlyAtTheWindowBoundary() {
+ let helper = windows("America/New_York")
+ let start = helper.start(of: date("2025-11-02"))!
+
+ XCTAssertEqual(helper.dayKey(for: start.addingTimeInterval(-1)), "2025-11-01")
+ XCTAssertEqual(helper.dayKey(for: start.addingTimeInterval(1)), "2025-11-02")
+ }
+
+ /// The key holds for all 25 hours of the fall-back day, including the repeated local hour.
+ func testDayKeyHoldsAcrossTheWholeOfATwentyFiveHourDay() {
+ let helper = windows("America/New_York")
+ let window = helper.window(of: date("2025-11-02"))!
+
+ XCTAssertEqual(helper.dayKey(for: window.start), "2025-11-02")
+ XCTAssertEqual(helper.dayKey(for: utc("2025-11-02T05:30:00Z")), "2025-11-02")
+ XCTAssertEqual(helper.dayKey(for: window.nextStart.addingTimeInterval(-1)), "2025-11-02")
+ }
+
+ // MARK: - An ambiguous or missing local midnight resolves by a stated rule
+
+ /// A date with no 00:00 starts at its first existing instant, its local 01:00.
+ func testTransitionThatSkipsLocalMidnight() {
+ let start = windows("America/Santiago").start(of: date("2025-09-07"))
+
+ XCTAssertEqual(start, utc("2025-09-07T04:00:00Z"))
+ }
+
+ /// A date with two 00:00 starts at the earlier of them.
+ func testTransitionThatRepeatsLocalMidnight() {
+ let helper = windows("America/Havana")
+
+ let start = helper.start(of: date("2025-11-02"))
+
+ XCTAssertEqual(start, utc("2025-11-02T04:00:00Z"))
+ XCTAssertEqual(helper.offsetSeconds(at: start!), -14_400)
+ // The later of the two midnights, which the rule must not pick.
+ XCTAssertEqual(helper.offsetSeconds(at: utc("2025-11-02T05:00:00Z")), -18_000)
+ }
+
+ // MARK: - A date that does not exist is skipped over
+
+ /// A calendar date the zone skipped has no start at all.
+ func testSkippedCalendarDateProducesNoStart() {
+ XCTAssertNil(windows("Pacific/Apia").start(of: date("2011-12-30")))
+ }
+
+ /// The preceding date's window reaches over the skipped date to the next existing one.
+ func testPrecedingDatesWindowReachesTheNextExistingDate() {
+ let helper = windows("Pacific/Apia")
+
+ let window = helper.window(of: date("2011-12-29"))
+
+ XCTAssertEqual(window?.nextStart, helper.start(of: date("2011-12-31")))
+ XCTAssertEqual(window?.duration, 24 * 3600)
+ }
+
+ // MARK: - A backward run yields consecutive existing dates with their own starts
+
+ /// A run spanning the autumn transition is consecutive and each entry carries its own start.
+ func testRunAcrossATransitionIsConsecutiveAndCorrectlyAnchored() {
+ let helper = windows("America/New_York")
+
+ let run = helper.backwardRun(endingAt: date("2025-11-10"), count: 21)
+
+ XCTAssertEqual(run.count, 21)
+ XCTAssertEqual(run.first?.date.key, "2025-10-21")
+ XCTAssertEqual(run.last?.date.key, "2025-11-10")
+ for (index, entry) in run.enumerated() {
+ XCTAssertEqual(entry.date.daysSinceEpoch,
+ date("2025-10-21").daysSinceEpoch + index,
+ "run must be consecutive at \(entry.date.key)")
+ XCTAssertEqual(entry.start, helper.start(of: entry.date), entry.date.key)
+ }
+ }
+
+ /// A run containing a skipped date is shorter than requested and omits it.
+ func testRunContainingASkippedDateIsShorterThanRequested() {
+ let run = windows("Pacific/Apia").backwardRun(endingAt: date("2012-01-02"), count: 5)
+
+ XCTAssertEqual(run.count, 4)
+ XCTAssertEqual(run.first?.date.key, "2011-12-29")
+ XCTAssertEqual(run.map(\.date.key), ["2011-12-29", "2011-12-31", "2012-01-01", "2012-01-02"])
+ }
+
+ /// A long run lands on the right calendar date, 3999 days before the anchor.
+ func testLongRunReachesTheRightCalendarDate() {
+ let run = windows("America/New_York").backwardRun(endingAt: date("2025-11-10"), count: 4000)
+
+ XCTAssertEqual(run.count, 4000)
+ XCTAssertEqual(run.first?.date.key, "2014-11-29")
+ XCTAssertEqual(run.last?.date.key, "2025-11-10")
+ }
+
+ // MARK: - The sleep read window never extends into the future
+
+ /// The current date's window stops at the reference instant instead of its next start.
+ func testCurrentDatesWindowStopsAtTheReferenceInstant() {
+ let helper = windows("America/New_York", reference: utc("2025-11-02T18:00:00Z"))
+
+ let end = helper.sleepReadWindowEnd(of: date("2025-11-02"))
+
+ XCTAssertEqual(end, utc("2025-11-02T18:00:00Z"))
+ XCTAssertNotEqual(end, helper.window(of: date("2025-11-02"))?.nextStart)
+ }
+
+ /// A past 25-hour day reaches its own next start, 25 hours after it began.
+ func testPastTwentyFiveHourDayReachesItsOwnNextStart() {
+ let helper = windows("America/New_York", reference: utc("2025-12-15T12:00:00Z"))
+
+ let end = helper.sleepReadWindowEnd(of: date("2025-11-02"))
+ let window = helper.window(of: date("2025-11-02"))!
+
+ XCTAssertEqual(end, window.nextStart)
+ XCTAssertEqual(end?.timeIntervalSince(window.start), 25 * 3600)
+ }
+
+ // MARK: - The default zone and reference instant are named, not implied
+
+ /// The default zone is the auto-updating current zone named in the design, not a snapshot of it.
+ func testDefaultZoneIsThePlatformsTrackingDeviceZone() {
+ XCTAssertEqual(LocalDayWindows.defaultTimeZone, TimeZone.autoupdatingCurrent)
+ XCTAssertEqual(LocalDayWindows().timeZone, TimeZone.autoupdatingCurrent)
+ XCTAssertEqual(LocalDayWindows(referenceInstant: Date()).timeZone, TimeZone.autoupdatingCurrent)
+ }
+
+ /// The default reference instant is the current `Date`, so it lies inside a bracket taken around
+ /// the call.
+ func testDefaultReferenceInstantIsThePlatformsCurrentInstant() {
+ let before = Date()
+ let accessor = LocalDayWindows.defaultReferenceInstant
+ let helperDefault = LocalDayWindows().referenceInstant
+ let after = Date()
+
+ XCTAssertGreaterThanOrEqual(accessor, before)
+ XCTAssertLessThanOrEqual(accessor, after)
+ XCTAssertGreaterThanOrEqual(helperDefault, before)
+ XCTAssertLessThanOrEqual(helperDefault, after)
+ }
+
+ // MARK: - Both platforms agree, proven by a committed oracle
+
+ /// The committed oracle, loaded by a path derived from this file's own location.
+ ///
+ /// It fails rather than skips when the file is absent: an oracle nobody can find would otherwise
+ /// let both platforms stay green while they disagree, which is the whole thing this file guards.
+ private func loadOracle() throws -> [String: Any] {
+ let relative = "android/app/src/test/resources/local_day_windows_oracle.json"
+ var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
+ for _ in 0..<8 {
+ let candidate = dir.appendingPathComponent(relative)
+ if FileManager.default.fileExists(atPath: candidate.path) {
+ let data = try Data(contentsOf: candidate)
+ let parsed = try JSONSerialization.jsonObject(with: data)
+ return try XCTUnwrap(parsed as? [String: Any], "the oracle must be a JSON object")
+ }
+ dir = dir.deletingLastPathComponent()
+ }
+ XCTFail("committed oracle \(relative) not found above \(#filePath) — this test must not pass by default")
+ throw CocoaError(.fileNoSuchFile)
+ }
+
+ /// Every value in the committed oracle, re-derived by the helper and compared.
+ ///
+ /// The Kotlin twin asserts the same file, so a change on either side reddens one of the two suites.
+ func testSwiftTestReadsTheSameCommittedFile() throws {
+ let oracle = try loadOracle()
+
+ let producedBy = try XCTUnwrap(oracle["producedBy"] as? [String: Any])
+ XCTAssertFalse(try XCTUnwrap(producedBy["toolchain"] as? String).isEmpty)
+ XCTAssertFalse(try XCTUnwrap(producedBy["timeZoneDataVersion"] as? String).isEmpty)
+
+ let recorded = try XCTUnwrap(producedBy["timeZoneDataVersion"] as? String)
+ let note = LocalDayWindows.timeZoneDataVersionNote(
+ recorded: recorded, observed: LocalDayWindows.observedTimeZoneDataVersion)
+
+ let starts = try XCTUnwrap(oracle["starts"] as? [[String: Any]])
+ XCTAssertEqual(starts.count, 19, "oracle section starts")
+ for record in starts {
+ let helper = windows(try XCTUnwrap(record["zone"] as? String))
+ let day = date(try XCTUnwrap(record["date"] as? String))
+ let expected = record["start"] as? Int
+ let context = "start \(helper.timeZone.identifier) \(day.key) — \(note)"
+ XCTAssertEqual(helper.start(of: day).map { Int($0.timeIntervalSince1970) }, expected, context)
+ }
+
+ let windowRecords = try XCTUnwrap(oracle["windows"] as? [[String: Any]])
+ XCTAssertEqual(windowRecords.count, 18, "oracle section windows")
+ for record in windowRecords {
+ let helper = windows(try XCTUnwrap(record["zone"] as? String))
+ let day = date(try XCTUnwrap(record["date"] as? String))
+ let window = try XCTUnwrap(helper.window(of: day))
+ let context = "window \(helper.timeZone.identifier) \(day.key) — \(note)"
+ XCTAssertEqual(Int(window.start.timeIntervalSince1970), record["start"] as? Int, context)
+ XCTAssertEqual(Int(window.nextStart.timeIntervalSince1970), record["nextStart"] as? Int, context)
+ XCTAssertEqual(window.utcOffsetSeconds, record["utcOffsetSeconds"] as? Int, context)
+ }
+
+ let containing = try XCTUnwrap(oracle["containingDayStarts"] as? [[String: Any]])
+ XCTAssertEqual(containing.count, 8, "oracle section containingDayStarts")
+ for record in containing {
+ let helper = windows(try XCTUnwrap(record["zone"] as? String))
+ let at = instant(try XCTUnwrap(record["instant"] as? Int))
+ let context = "containing \(helper.timeZone.identifier) \(record["instant"] ?? "?") — \(note)"
+ XCTAssertEqual(Int(helper.startOfDay(containing: at).timeIntervalSince1970),
+ record["start"] as? Int, context)
+ }
+
+ let keys = try XCTUnwrap(oracle["dayKeys"] as? [[String: Any]])
+ XCTAssertEqual(keys.count, 11, "oracle section dayKeys")
+ for record in keys {
+ let helper = windows(try XCTUnwrap(record["zone"] as? String))
+ let at = instant(try XCTUnwrap(record["instant"] as? Int))
+ let context = "key \(helper.timeZone.identifier) \(record["instant"] ?? "?") — \(note)"
+ XCTAssertEqual(helper.dayKey(for: at), record["key"] as? String, context)
+ }
+
+ let runs = try XCTUnwrap(oracle["runs"] as? [[String: Any]])
+ XCTAssertEqual(runs.count, 4, "oracle section runs")
+ for record in runs {
+ let helper = windows(try XCTUnwrap(record["zone"] as? String))
+ let anchor = date(try XCTUnwrap(record["endingAt"] as? String))
+ let requested = try XCTUnwrap(record["requested"] as? Int)
+ let run = helper.backwardRun(endingAt: anchor, count: requested)
+ let context = "run \(helper.timeZone.identifier) \(anchor.key)/\(requested) — \(note)"
+ XCTAssertEqual(run.count, record["size"] as? Int, context)
+ for (edge, entry) in [("oldest", run.first), ("newest", run.last)] {
+ let expected = try XCTUnwrap(record[edge] as? [String: Any], context)
+ XCTAssertEqual(entry?.date.key, expected["date"] as? String, "\(edge) \(context)")
+ XCTAssertEqual(entry.map { Int($0.start.timeIntervalSince1970) },
+ expected["start"] as? Int, "\(edge) \(context)")
+ }
+ // Short runs carry every entry; the 4000-day run carries its edges and its size only, so
+ // that the committed file stays reviewable, and says so in `daysTruncated`.
+ let days = try XCTUnwrap(record["days"] as? [[String: Any]], context)
+ if try XCTUnwrap(record["daysTruncated"] as? Bool, context) {
+ XCTAssertTrue(days.isEmpty, context)
+ } else {
+ XCTAssertEqual(days.count, run.count, context)
+ for (entry, expected) in zip(run, days) {
+ XCTAssertEqual(entry.date.key, expected["date"] as? String, context)
+ XCTAssertEqual(Int(entry.start.timeIntervalSince1970), expected["start"] as? Int, context)
+ }
+ }
+ }
+
+ let sleepEnds = try XCTUnwrap(oracle["sleepWindowEnds"] as? [[String: Any]])
+ XCTAssertEqual(sleepEnds.count, 5, "oracle section sleepWindowEnds")
+ for record in sleepEnds {
+ let reference = instant(try XCTUnwrap(record["referenceInstant"] as? Int))
+ let helper = windows(try XCTUnwrap(record["zone"] as? String), reference: reference)
+ let day = date(try XCTUnwrap(record["date"] as? String))
+ let context = "sleep end \(helper.timeZone.identifier) \(day.key) — \(note)"
+ XCTAssertEqual(helper.sleepReadWindowEnd(of: day).map { Int($0.timeIntervalSince1970) },
+ record["end"] as? Int, context)
+ }
+ }
+
+ /// The mismatch note names the recorded version and the observed one, and says so plainly where the
+ /// platform has no version of its own to report.
+ func testMismatchNoteNamesBothDatabaseVersions() {
+ let observed = LocalDayWindows.timeZoneDataVersionNote(recorded: "2025b", observed: "2025a")
+ XCTAssertEqual(observed,
+ "time-zone database version: the oracle records 2025b; this platform observes 2025a.")
+
+ let blind = LocalDayWindows.timeZoneDataVersionNote(recorded: "unavailable", observed: nil)
+ XCTAssertEqual(blind,
+ "time-zone database version: the oracle records unavailable; "
+ + "this platform cannot observe its own time-zone database version.")
+ XCTAssertTrue(blind.contains("cannot observe"))
+ XCTAssertTrue(blind.contains("unavailable"))
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PreSleepHeartRateFeedbackTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PreSleepHeartRateFeedbackTests.swift
new file mode 100644
index 0000000000..0dd4ad071a
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PreSleepHeartRateFeedbackTests.swift
@@ -0,0 +1,259 @@
+import XCTest
+@testable import StrandAnalytics
+import WhoopProtocol
+import WhoopStore
+
+final class PreSleepHeartRateFeedbackTests: XCTestCase {
+ private func samples(_ start: Int, bpm: Int, count: Int) -> [HRSample] {
+ (0.. (hr: [HRSample], start: Int, end: Int) {
- var hr: [HRSample] = []
- let totalSeconds = Int(hours * 3600)
- var t = 0
- while t < totalSeconds {
- let bpm = startBpm + slopePerHour * (Double(t) / 3600.0)
- hr.append(HRSample(ts: originTs + t, bpm: Int(bpm.rounded())))
- t += 30
- }
- return (hr, originTs, originTs + totalSeconds)
- }
-
- func testRecoveryIndexSlopeNilWhenNoSamples() {
- XCTAssertNil(RecoveryScorer.recoveryIndexSlope([], start: 0, end: 1000))
- }
-
- func testRecoveryIndexSlopeNilWhenTooFewBins() {
- // Only 2 five-minute bins (10 minutes) of data — below recoveryIndexMinBins — too
- // little of the night to fit a trend; must return nil, never a fabricated slope.
- var hr: [HRSample] = []
- let start = 5000
- for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 60)) }
- for i in 0..<300 { hr.append(HRSample(ts: start + 300 + i, bpm: 55)) }
- XCTAssertNil(RecoveryScorer.recoveryIndexSlope(hr, start: start, end: start + 600))
- }
-
- func testRecoveryIndexSlopeRecoversMultipleDistinctInjectedSlopes() {
- // Derived-signal rule: recover MULTIPLE distinct injected slopes, not one matched case.
- // Four full-night (6 h) synthetic series: flat, mild decline, steep decline, rising.
- let flat = slopeSeries(startBpm: 62, slopePerHour: 0.0, hours: 6)
- let mild = slopeSeries(startBpm: 62, slopePerHour: -1.0, hours: 6)
- let steep = slopeSeries(startBpm: 68, slopePerHour: -4.0, hours: 6)
- let rising = slopeSeries(startBpm: 55, slopePerHour: 2.0, hours: 6)
-
- let flatSlope = RecoveryScorer.recoveryIndexSlope(flat.hr, start: flat.start, end: flat.end)
- let mildSlope = RecoveryScorer.recoveryIndexSlope(mild.hr, start: mild.start, end: mild.end)
- let steepSlope = RecoveryScorer.recoveryIndexSlope(steep.hr, start: steep.start, end: steep.end)
- let risingSlope = RecoveryScorer.recoveryIndexSlope(rising.hr, start: rising.start, end: rising.end)
-
- XCTAssertNotNil(flatSlope); XCTAssertNotNil(mildSlope)
- XCTAssertNotNil(steepSlope); XCTAssertNotNil(risingSlope)
-
- // Each recovered slope is close to its OWN injected value (within integer-bpm rounding
- // noise from the synthetic series), not just relatively ordered.
- XCTAssertEqual(flatSlope!, 0.0, accuracy: 0.3)
- XCTAssertEqual(mildSlope!, -1.0, accuracy: 0.3)
- XCTAssertEqual(steepSlope!, -4.0, accuracy: 0.3)
- XCTAssertEqual(risingSlope!, 2.0, accuracy: 0.3)
-
- // And strictly ordered steep-decline < mild-decline < flat < rising.
- XCTAssertLessThan(steepSlope!, mildSlope!)
- XCTAssertLessThan(mildSlope!, flatSlope!)
- XCTAssertLessThan(flatSlope!, risingSlope!)
- }
-
// MARK: - Recovery Index / Activity-Balance folded into recovery(...)
func testRecoveryIndexAndActivityBalanceDefaultNilByteIdenticalToBefore() {
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhrBinGateDiagnosticTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhrBinGateDiagnosticTests.swift
new file mode 100644
index 0000000000..ec5b5c4a1a
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RhrBinGateDiagnosticTests.swift
@@ -0,0 +1,86 @@
+import XCTest
+import WhoopProtocol
+@testable import StrandAnalytics
+
+/// #1943 conformance check: the line reports when the gate MOVED the floor by comparing the UNGATED
+/// floor (the old rule: min over every non-empty bin) against the shipped one (which IS the gated
+/// floor). The line fires when the gate excluded the bin that would otherwise have won — which is
+/// the frequency and magnitude the measure-only diagnostic was meant to learn. Byte-parity twin of
+/// Kotlin `RhrBinGateDiagnosticTest`.
+final class RhrBinGateDiagnosticTests: XCTestCase {
+
+ private func hr(_ start: Int, _ count: Int, _ bpm: Int) -> [HRSample] {
+ (0.. StepSample {
+ StepSample(ts: ts, counter: counter, activityClass: cls)
+ }
+ private func sleep(stages: [StageSegment] = []) -> SleepSession {
+ SleepSession(start: 0, end: 100, efficiency: 1, stages: stages, restingHR: nil, avgHRV: nil)
+ }
+
+ func testIsolatedBedMotionIsRejected() {
+ XCTAssertNil(SleepAwareStepCounter.stepsInWindow([
+ sample(0, 0), sample(1, 1), sample(20, 2)
+ ], sleepSessions: [sleep()]))
+ }
+
+ func testCoherentNightWalkCounts() {
+ XCTAssertEqual(SleepAwareStepCounter.stepsInWindow([
+ sample(0, 0), sample(1, 2), sample(2, 4), sample(3, 6), sample(4, 8), sample(5, 10)
+ ], sleepSessions: [sleep()]), 10)
+ }
+
+ func testExplicitWakeGapCountsImmediately() {
+ let wake = StageSegment(start: 10, end: 20, stage: "wake")
+ XCTAssertEqual(SleepAwareStepCounter.stepsInWindow([
+ sample(9, 0), sample(10, 2)
+ ], sleepSessions: [sleep(stages: [wake])]), 2)
+ }
+
+ func testDiagnosticRejectionReasonsAreSeparated() {
+ let count = SleepAwareStepCounter.count([
+ sample(0, 100, 0), sample(1, 102, 1), sample(2, 110, 1),
+ sample(4, 117, 2), sample(5, 120, 0)
+ ], sleepSessions: [])
+ XCTAssertEqual(count.totalTicks, 9)
+ XCTAssertEqual(count.rejectedActivityClassTicks, 3)
+ XCTAssertEqual(count.rejectedImplausibleTicks, 8)
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift
index 1a2eebd690..b1720de223 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepDebtTests.swift
@@ -15,19 +15,18 @@ final class SleepDebtTests: XCTestCase {
XCTAssertEqual(l.needMin, 480.0, accuracy: 1e-9)
}
- /// A surplus night offsets a deficit one (ledger nets credits and debits).
- func testSurplusOffsetsDeficit() {
+ /// Debt carries forward as part of the following night's need; a night that meets
+ /// that complete need clears it instead of producing a positive surplus.
+ func testDebtRecursAndMeetingNeedClearsIt() {
let series: [(day: String, totalSleepMin: Double?)] = [
("2026-06-01", 360), // −120
- ("2026-06-02", 540), // +60
- ("2026-06-03", 420), // −60
+ ("2026-06-02", 546), // base 480 + prior debt 66
+ ("2026-06-03", 540), // raw bar remains +60
]
- // need 8 h = 480. Σ = −120 + 60 − 60 = −120.
let l = SleepDebt.ledger(series: series, needHours: 8.0)
- XCTAssertEqual(l.balanceMin, -120.0, accuracy: 1e-9)
- XCTAssertTrue(l.isDebt)
- XCTAssertEqual(l.magnitudeMin, 120.0, accuracy: 1e-9)
- XCTAssertEqual(l.nights.map { $0.deltaMin }, [-120, 60, -60])
+ XCTAssertEqual(l.balanceMin, 0.0, accuracy: 1e-9)
+ XCTAssertFalse(l.isDebt)
+ XCTAssertEqual(l.nights.map { $0.deltaMin }, [-120, 66, 60])
}
/// Nights with no usable sleep are skipped entirely (never zero-filled as debt).
@@ -40,19 +39,20 @@ final class SleepDebtTests: XCTestCase {
]
let l = SleepDebt.ledger(series: series, needHours: 8.0)
XCTAssertEqual(l.nightCount, 2)
- XCTAssertEqual(l.balanceMin, -60.0, accuracy: 1e-9)
+ XCTAssertEqual(l.balanceMin, -33.0, accuracy: 1e-9)
XCTAssertEqual(l.nights.map { $0.day }, ["2026-06-01", "2026-06-04"])
}
/// Only the most-recent `window` COUNTED nights are in scope.
func testWindowCapKeepsMostRecent() {
- // 16 nights, each 60 min UNDER need → each delta −60.
+ // 16 nights, each 60 min UNDER base need. The recurrence is computed only
+ // across the retained 14 counted nights, preserving the existing window.
let series: [(day: String, totalSleepMin: Double?)] = (1...16).map {
(String(format: "2026-06-%02d", $0), Double(420))
}
let l = SleepDebt.ledger(series: series, needHours: 8.0, window: 14)
XCTAssertEqual(l.nightCount, 14) // capped
- XCTAssertEqual(l.balanceMin, -840.0, accuracy: 1e-9) // 14 × −60
+ XCTAssertEqual(l.balanceMin, -73.3, accuracy: 1e-9)
XCTAssertEqual(l.nights.first?.day, "2026-06-03") // oldest kept
XCTAssertEqual(l.nights.last?.day, "2026-06-16") // newest kept
}
@@ -72,7 +72,7 @@ final class SleepDebtTests: XCTestCase {
func testDefaultNeedIsEightHours() {
let l = SleepDebt.ledger(series: [("2026-06-01", 420)])
XCTAssertEqual(l.needMin, AnalyticsEngine.Rest.defaultNeedHours * 60.0, accuracy: 1e-9)
- XCTAssertEqual(l.balanceMin, -60.0, accuracy: 1e-9)
+ XCTAssertEqual(l.balanceMin, -33.0, accuracy: 1e-9)
}
/// Main sleep remains the canonical nightly figure, while a separately-recorded nap
@@ -81,7 +81,7 @@ final class SleepDebtTests: XCTestCase {
let credited = try XCTUnwrap(SleepDebt.creditedSleepMin(mainSleepMin: 392, napSleepMin: 48))
XCTAssertEqual(credited, 440, accuracy: 1e-9)
let l = SleepDebt.ledger(series: [("2026-06-01", credited)], needHours: 8.0)
- XCTAssertEqual(l.balanceMin, -40, accuracy: 1e-9)
+ XCTAssertEqual(l.balanceMin, -22, accuracy: 1e-9)
}
func testNapCreditRequiresMainSleepAndIgnoresNegativeNapMinutes() {
@@ -90,20 +90,82 @@ final class SleepDebtTests: XCTestCase {
XCTAssertEqual(SleepDebt.creditedSleepMin(mainSleepMin: 392, napSleepMin: -10), 392.0)
}
- /// A NEGATIVE EXACT half-tie balance rounds AWAY from zero (−0.05 → −0.1), the documented
- /// `round1` contract the Kotlin mirror must match (audit #6). needMin = 0.1, slept 0.05 →
- /// delta −0.05 exactly → balance −0.1. Kotlin's old `roundToInt()` (half toward +∞) gave
- /// 0.0 on this exact tie — the real divergence this pins shut.
- func testNegativeHalfTieRoundsAwayFromZero() {
- let l = SleepDebt.ledger(series: [("2026-06-01", 0.05)], needHours: 0.1 / 60.0)
- XCTAssertEqual(l.balanceMin, -0.1, accuracy: 1e-9) // away-from-zero, not 0.0
+ func testDebtBelowTenMinutesClearsButExactlyTenRemains() {
+ XCTAssertEqual(SleepDebt.onTargetBandMin, 10, accuracy: 1e-9)
+ // 18 min shortfall × 0.55 = 9.9 min -> cleared.
+ XCTAssertEqual(
+ SleepDebt.ledger(series: [("2026-06-01", 462)], needHours: 8).balanceMin,
+ 0,
+ accuracy: 1e-9)
+ // A deliberately exact 10 min calculated debt remains debt.
+ let slept = 480.0 - (10.0 / 0.55)
+ XCTAssertEqual(
+ SleepDebt.ledger(series: [("2026-06-01", slept)], needHours: 8).balanceMin,
+ -10,
+ accuracy: 1e-9)
+ }
+
+ func testSurplusNeverCreatesPositiveBalanceAndRawBarsStayUnchanged() {
+ let l = SleepDebt.ledger(series: [
+ ("2026-06-01", 600),
+ ("2026-06-02", 420),
+ ], needHours: 8)
+ XCTAssertEqual(l.balanceMin, -33, accuracy: 1e-9)
+ XCTAssertEqual(l.nights.map(\.deltaMin), [120, -60])
+ }
+
+ func testImportedDebtIsVerbatimButDoesNotSeedFollowingFallback() {
+ let values = SleepDebt.debtSeries(
+ series: [("2026-06-01", 420), ("2026-06-02", 480)],
+ needHours: 8,
+ importedDebtMin: ["2026-06-01": 61.25])
+ XCTAssertEqual(values[0].value, 61.25, accuracy: 1e-9)
+ XCTAssertEqual(values[1].value, 18.2, accuracy: 1e-9)
+ }
+
+ func testDebtSeriesRoundsLocalOutputsToLedgerPrecision() {
+ let input: [(day: String, totalSleepMin: Double?)] = [
+ ("2026-06-01", 420), ("2026-06-02", 410),
+ ]
+ let values = SleepDebt.debtSeries(series: input, needHours: 8)
+ let ledger = SleepDebt.ledger(series: input, needHours: 8)
+ XCTAssertEqual(values.last?.value ?? -1, 56.7, accuracy: 1e-9)
+ XCTAssertEqual(values.last?.value ?? -1, ledger.magnitudeMin, accuracy: 1e-9)
+ }
+
+ func testImportedOnlyGapDoesNotConsumeOneOfFourteenUsableNights() {
+ let usable: [(day: String, totalSleepMin: Double?)] = (1...14).map {
+ (String(format: "2026-06-%02d", $0), 420)
+ }
+ let input = Array(usable.prefix(13))
+ + [(day: "2026-06-14-imported-only", totalSleepMin: nil)]
+ + Array(usable.suffix(1))
+ let values = SleepDebt.debtSeries(
+ series: input,
+ needHours: 8,
+ importedDebtMin: ["2026-06-14-imported-only": 91.25])
+
+ XCTAssertEqual(values.count, 15)
+ XCTAssertEqual(values[13].value, 91.25, accuracy: 1e-9)
+ XCTAssertEqual(values.last?.value ?? -1,
+ SleepDebt.ledger(series: usable, needHours: 8, window: 14).magnitudeMin,
+ accuracy: 1e-9)
}
- /// The symmetric POSITIVE exact half-tie (+0.05 → +0.1), pinned so the sign-aware Kotlin
- /// rounding can't silently regress one direction. needMin = 0, slept 0.05 → delta +0.05.
- func testPositiveHalfTieRoundsAwayFromZero() {
- let l = SleepDebt.ledger(series: [("2026-06-01", 0.05)], needHours: 0.0)
- XCTAssertEqual(l.balanceMin, 0.1, accuracy: 1e-9)
+ func testDebtSeriesKeepsFullHistoryWithPerDayTrailingLedgerParity() {
+ let input: [(day: String, totalSleepMin: Double?)] = (1...16).map {
+ (String(format: "2026-06-%02d", $0), 390 + Double($0))
+ }
+ let values = SleepDebt.debtSeries(series: input, needHours: 8, window: 14)
+
+ XCTAssertEqual(values.count, 16)
+ for index in values.indices {
+ let expected = SleepDebt.ledger(
+ series: Array(input.prefix(index + 1)), needHours: 8, window: 14).magnitudeMin
+ XCTAssertEqual(values[index].day, input[index].day)
+ XCTAssertEqual(values[index].value, expected, accuracy: 1e-9,
+ "per-day parity at \(values[index].day)")
+ }
}
/// round1 is exercised on the directly-constructed value too, so the rounding mode is
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepNightLabelTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepNightLabelTests.swift
new file mode 100644
index 0000000000..41ffb46410
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepNightLabelTests.swift
@@ -0,0 +1,94 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// Swift twin of `SleepHeroLogicTest`. The Kotlin side has had these since the logic was written; the
+/// Swift side had none, because the function lived private inside the view — which is why the defect
+/// below went uncaught on this platform until a report arrived.
+final class SleepNightLabelTests: XCTestCase {
+
+ private var utc: Calendar = {
+ var c = Calendar(identifier: .gregorian)
+ c.timeZone = TimeZone(identifier: "UTC")!
+ return c
+ }()
+
+ /// A wake at `hour` on the given day, as an epoch second.
+ private func wake(_ y: Int, _ m: Int, _ d: Int, _ hour: Int = 7) -> Int {
+ var c = DateComponents()
+ c.year = y; c.month = m; c.day = d; c.hour = hour
+ return Int(utc.date(from: c)!.timeIntervalSince1970)
+ }
+
+ private func day(_ y: Int, _ m: Int, _ d: Int, _ hour: Int = 12) -> Date {
+ var c = DateComponents()
+ c.year = y; c.month = m; c.day = d; c.hour = hour
+ return utc.date(from: c)!
+ }
+
+ /// The bug. Measured from the newest RECORDED night, offset 0 was always zero, so the hero read
+ /// "Last night" over a night days old — beside the correct date, contradicting it.
+ func testAStaleNewestNightIsNotCalledLastNight() {
+ let n = SleepNightLabel.nightsAgo(
+ wakeTimestamps: [wake(2026, 9, 5)] as [Int?], offset: 0,
+ today: day(2026, 9, 7), calendar: utc)
+ XCTAssertEqual(n, 2)
+ }
+
+ func testTheNightThatEndedThisMorningIsStillLastNight() {
+ let n = SleepNightLabel.nightsAgo(
+ wakeTimestamps: [wake(2026, 9, 7)] as [Int?], offset: 0,
+ today: day(2026, 9, 7), calendar: utc)
+ XCTAssertEqual(n, 0)
+ }
+
+ /// Calendar distance, not carousel index: a night with no data is skipped by the carousel, so
+ /// labelling by index would make the nights either side of it read as consecutive.
+ func testCountsCalendarNightsNotCarouselIndex() {
+ let nights: [Int?] = [wake(2026, 8, 13), wake(2026, 8, 10)]
+ XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 0,
+ today: day(2026, 8, 13), calendar: utc), 0)
+ XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 1,
+ today: day(2026, 8, 13), calendar: utc), 3)
+ }
+
+ /// Only TODAY is rolled; the shown night keeps its calendar wake-date, because that is what the
+ /// carousel groups by. Rolling both sides collapsed two distinct entries onto one label.
+ func testTwoNightsEitherSideOfTheRollKeepDistinctLabels() {
+ let nights: [Int?] = [wake(2026, 9, 7, 2), wake(2026, 9, 6, 7)]
+ XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 0,
+ today: day(2026, 9, 7), calendar: utc), 0)
+ XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 1,
+ today: day(2026, 9, 7), calendar: utc), 1)
+ }
+
+ /// The pre-roll window, which lands on the NEGATIVE branch and gets the right answer from what
+ /// reads like an error fallback. Wake at 02:00, check at 03:00: the night's calendar date is the
+ /// 7th while the logical day is still the 6th, so the distance is -1 and offset 0 is genuinely
+ /// "Last night". Pinned so a later tightening of that branch cannot break it silently.
+ func testANightWokenBeforeTheRollStillReadsLastNight() {
+ let n = SleepNightLabel.nightsAgo(
+ wakeTimestamps: [wake(2026, 9, 7, 2)] as [Int?], offset: 0,
+ today: day(2026, 9, 6, 23), calendar: utc)
+ XCTAssertEqual(n, 0)
+ }
+
+ func testOutOfRangeFallsBackToTheIndex() {
+ XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: [] as [Int?], offset: 5,
+ today: day(2026, 9, 7), calendar: utc), 5)
+ }
+
+ /// A carousel entry with no session falls back to the offset, exactly as the Kotlin twin does.
+ /// Mapping it to 0 would put the night in 1970 and print roughly twenty thousand nights ago.
+ ///
+ /// The dates are chosen so the fallback is DISTINGUISHABLE: at offset 1 the answer is 1 only
+ /// because the guard fired, since the real night sits fourteen nights back and an unguarded 1970
+ /// would be five figures. A nil at offset 0 against a same-day today would have returned 0 either
+ /// way and proved nothing.
+ func testAnEntryWithNoSessionFallsBackToTheIndex() {
+ let nights: [Int?] = [wake(2026, 9, 6), nil]
+ XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 0,
+ today: day(2026, 9, 20), calendar: utc), 14)
+ XCTAssertEqual(SleepNightLabel.nightsAgo(wakeTimestamps: nights, offset: 1,
+ today: day(2026, 9, 20), calendar: utc), 1)
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerActiveBridgeTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerActiveBridgeTests.swift
new file mode 100644
index 0000000000..ac82c1831d
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerActiveBridgeTests.swift
@@ -0,0 +1,256 @@
+import XCTest
+@testable import StrandAnalytics
+import WhoopProtocol
+
+/// #1657 twin of `SleepStagerActiveBridgeTest`. The sparse-gravity bridge could only ever join sleep
+/// runs already adjacent in its own output, so ANY active run between two sleep runs blocked the merge
+/// permanently. A field trace found it merging nothing on 14 of 14 sparse nights for exactly that reason
+/// — and since a bathroom trip is definitionally an active run, the rescue built for fragmentation was
+/// unavailable in the case that needs it most. The pieces then died at the 60-minute session floor,
+/// which is how a 6h40m night scored 150 minutes.
+final class SleepStagerActiveBridgeTests: XCTestCase {
+
+ private func sleep(_ start: Int, _ end: Int) -> SleepStager.Period {
+ SleepStager.Period(stage: "sleep", start: start, end: end)
+ }
+ private func active(_ start: Int, _ end: Int) -> SleepStager.Period {
+ SleepStager.Period(stage: "active", start: start, end: end)
+ }
+ /// Flat HR well under the band, so the HR gate is never the thing under test.
+ private func calmHr(_ from: Int, _ to: Int, bpm: Int = 50) -> [HRSample] {
+ stride(from: from, through: to, by: 60).map { HRSample(ts: $0, bpm: bpm) }
+ }
+ private let baseline = 60.0
+
+ /// THE reported shape: asleep, a short trip, asleep again. Each piece is under the 60-minute session
+ /// floor on its own; together they are a night.
+ func testAShortActiveInterruptionBetweenTwoSleepRunsIsAbsorbed() {
+ let out = SleepStager.bridgeSparseSleep(
+ [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)],
+ sparse: true, hr: calmHr(0, 9000), baseline: baseline)
+ XCTAssertEqual(out.count, 1)
+ XCTAssertEqual(out.first?.stage, "sleep")
+ XCTAssertEqual(out.first?.start, 0)
+ XCTAssertEqual(out.first?.end, 9000)
+ }
+
+ /// The guard that keeps this honest. A long active run is a real break in the night, not a stir, and
+ /// absorbing it would score wakefulness as sleep — wrong in a new direction and harder to notice than
+ /// the truncation being fixed.
+ func testAnActiveRunLongerThanTheBoundIsLeftAlone() {
+ // Repinned past the IN-BAND bound: this HR is calm, so that is the bound in force.
+ let tooLong = SleepStager.sparseBridgeActiveMaxInBandMin * 60 + 60
+ let out = SleepStager.bridgeSparseSleep(
+ [sleep(0, 3000), active(3000, 3000 + tooLong), sleep(3000 + tooLong, 9000)],
+ sparse: true, hr: calmHr(0, 9000), baseline: baseline)
+ XCTAssertEqual(out.count, 3)
+ }
+
+ /// HR is the real gate, not the duration bound. A wearer who is genuinely up keeps HR elevated for
+ /// the whole interruption, and that must still block the merge even when it is short.
+ func testAShortInterruptionWithElevatedHRIsNotAbsorbed() {
+ let hot = calmHr(0, 3000) + calmHr(3001, 3900, bpm: 110) + calmHr(3901, 9000)
+ let out = SleepStager.bridgeSparseSleep(
+ [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)],
+ sparse: true, hr: hot, baseline: baseline)
+ XCTAssertEqual(out.count, 3)
+ }
+
+ /// Two consecutive active runs are a night with structure in it, not one interruption.
+ func testTwoConsecutiveActiveRunsAreNotAbsorbed() {
+ let out = SleepStager.bridgeSparseSleep(
+ [sleep(0, 3000), active(3000, 3300), active(3300, 3900), sleep(3900, 9000)],
+ sparse: true, hr: calmHr(0, 9000), baseline: baseline)
+ XCTAssertEqual(out.count, 4)
+ }
+
+ /// A dense 4.0 night must be byte-identical: the bridge is sparse-only and always has been.
+ /// `Period` is not Equatable in production, so the fields are compared rather than widening a
+ /// production type for a test's convenience.
+ func testADenseNightIsUntouched() {
+ let periods = [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)]
+ let out = SleepStager.bridgeSparseSleep(periods, sparse: false, hr: calmHr(0, 9000),
+ baseline: baseline)
+ XCTAssertEqual(out.map(\.stage), periods.map(\.stage))
+ XCTAssertEqual(out.map(\.start), periods.map(\.start))
+ XCTAssertEqual(out.map(\.end), periods.map(\.end))
+ }
+
+ /// The pre-existing behaviour — a bare gap between two sleep runs — still merges.
+ func testTheOriginalAdjacentPairMergeStillWorks() {
+ let out = SleepStager.bridgeSparseSleep(
+ [sleep(0, 3000), sleep(3600, 9000)],
+ sparse: true, hr: calmHr(0, 9000), baseline: baseline)
+ XCTAssertEqual(out.count, 1)
+ XCTAssertEqual(out.first?.end, 9000)
+ }
+
+ /// The trace has to say WHY, and the blocking length is the number a reader needs. The old trace could
+ /// only report runsBefore == runsAfter, which says the bridge did nothing and not what stopped it.
+ func testABlockedPairReportsTheBoundThatBlockedItWithTheActiveLength() {
+ // Repinned past the IN-BAND bound: this HR is calm, so that is the bound in force.
+ let tooLong = SleepStager.sparseBridgeActiveMaxInBandMin * 60 + 60
+ let (_, attempts) = SleepStager.bridgeSparseSleepTraced(
+ [sleep(0, 3000), active(3000, 3000 + tooLong), sleep(3000 + tooLong, 9000)],
+ sparse: true, hr: calmHr(0, 9000), baseline: baseline)
+ XCTAssertEqual(attempts.count, 1)
+ XCTAssertEqual(attempts.first?.reason, "activeTooLong")
+ XCTAssertEqual(attempts.first?.bridged, false)
+ XCTAssertEqual(attempts.first?.activeMin, SleepStager.sparseBridgeActiveMaxInBandMin + 1)
+ // And it names WHICH bound, or the same activeMin reads blocked in one log, merged in another.
+ XCTAssertEqual(attempts.first?.activeCapMin, SleepStager.sparseBridgeActiveMaxInBandMin)
+ }
+
+ /// The tracer and the merge are ONE pass now. This file used to keep a shadow copy of the loop purely
+ /// to trace it, which had to be edited in step with the real one — a trace that quietly disagrees
+ /// with the behaviour it describes is worse than no trace at all.
+ func testTheTracedPassReturnsExactlyWhatThePlainOneDoes() {
+ let periods = [sleep(0, 3000), active(3000, 3900), sleep(3900, 9000)]
+ let hr = calmHr(0, 9000)
+ let plain = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: hr, baseline: baseline)
+ let traced = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr,
+ baseline: baseline).0
+ XCTAssertEqual(plain.map(\.stage), traced.map(\.stage))
+ XCTAssertEqual(plain.map(\.start), traced.map(\.start))
+ XCTAssertEqual(plain.map(\.end), traced.map(\.end))
+ }
+
+ /// #1657, the other half: `hrSleepBandAcross` judged on the MEAN, which a single arousal spike drags
+ /// out of band — the exact statistic `confirmSleepWithHR` documents as wrong for this, and uses the
+ /// median for instead. A sustained elevation must still be rejected.
+ func testABriefSpikeNoLongerPutsTheWholeIntervalOutOfBandButASustainedOneDoes() {
+ let spiky = calmHr(0, 3540) + calmHr(3541, 3660, bpm: 190)
+ XCTAssertTrue(SleepStager.hrSleepBandAcross(0, 3660, hr: spiky, baseline: baseline))
+ XCTAssertFalse(SleepStager.hrSleepBandAcross(0, 3660, hr: calmHr(0, 3660, bpm: 110),
+ baseline: baseline))
+ }
+
+ // MARK: - End to end, through detectSleep
+ //
+ // The unit cases above pin the bridge. These pin the thing the reporter actually saw: a night with
+ // one interruption arriving as ONE session instead of a truncated fragment.
+ //
+ // This twin is not optional. The Kotlin version of exactly this test is what found the bound wrong —
+ // every unit case passed at 20 while the real pipeline still returned two sessions, because
+ // classifyStill smears the still/moving boundary and buildRuns closes at sample edges, so a 15-minute
+ // interruption becomes a 21-minute run. That smear is a property of THIS implementation, so without
+ // the twin a divergence in Swift's windowing would leave the bound silently wrong on Apple only.
+
+ /// 2025-06-10 00:00:00 UTC.
+ private var refMidnight: Int { 1_749_513_600 }
+ private func at(_ hour: Int, _ minute: Int = 0) -> Int { refMidnight + hour * 3_600 + minute * 60 }
+
+ /// Still gravity at 1/min — constant orientation, so every delta is 0.
+ private func stillG(_ from: Int, _ toExclusive: Int) -> [GravitySample] {
+ stride(from: from, to: toExclusive, by: 60).map { GravitySample(ts: $0, x: 0, y: 0, z: 1.0) }
+ }
+
+ /// Moving gravity at 1/min — orientation swings every sample, so deltas are large.
+ private func movingG(_ from: Int, _ toExclusive: Int) -> [GravitySample] {
+ stride(from: from, to: toExclusive, by: 60).enumerated().map { i, t in
+ i % 2 == 0 ? GravitySample(ts: t, x: 1.0, y: 0, z: 0)
+ : GravitySample(ts: t, x: 0, y: 1.0, z: 0)
+ }
+ }
+
+ private func hr1Hz(_ from: Int, _ toExclusive: Int, _ bpm: Int) -> [HRSample] {
+ (from ..< toExclusive).map { HRSample(ts: $0, bpm: bpm) }
+ }
+
+ /// A night with a 30-minute gravity dropout early (so it reads sparse, as a 5/MG's does) and a
+ /// 15-minute up-and-about in the middle.
+ private func interruptedNight(tripBpm: Int) -> ([HRSample], [GravitySample]) {
+ let grav = stillG(at(0), at(0, 30))
+ + stillG(at(1), at(2))
+ + movingG(at(2), at(2, 15))
+ + stillG(at(2, 15), at(6))
+ let hr = hr1Hz(at(0), at(2), 50)
+ + hr1Hz(at(2), at(2, 15), tripBpm)
+ + hr1Hz(at(2, 15), at(6), 50)
+ return (hr, grav)
+ }
+
+ func testANightWithOneShortInterruptionIsDetectedAsASingleSession() {
+ let (hr, grav) = interruptedNight(tripBpm: 52)
+ XCTAssertTrue(SleepStager.isGravitySparse(grav, hr: hr), "the fixture must read as sparse")
+ let sessions = SleepStager.detectSleep(hr: hr, gravity: grav)
+ XCTAssertEqual(sessions.count, 1, "the interruption must not end the night")
+ let spanMin = Double((sessions[0].end - sessions[0].start)) / 60.0
+ XCTAssertGreaterThan(spanMin, 5 * 60, "the whole night should survive, got \(spanMin) min")
+ }
+
+ /// The same night with the wearer genuinely up — HR elevated for the whole quarter hour. Absorbing
+ /// that would score wakefulness as sleep: wrong in a new direction and harder to notice than the
+ /// truncation being fixed.
+ func testTheSameNightWithAGenuinelyAwakeInterruptionIsNotBridgedIntoOne() {
+ let (hr, grav) = interruptedNight(tripBpm: 110)
+ let sessions = SleepStager.detectSleep(hr: hr, gravity: grav)
+ let spanMin = sessions.reduce(0.0) { $0 + Double($1.end - $1.start) / 60.0 }
+ XCTAssertTrue(sessions.count != 1 || spanMin < 5 * 60,
+ "an awake interruption must not be absorbed (\(sessions.count) sessions, \(spanMin) min)")
+ }
+
+ /// The RENDERED line, not just the fields — and deliberately the whole string, byte for byte.
+ ///
+ /// The Kotlin twin asserts this identical literal. Nothing in the tree compares the two languages'
+ /// trace output automatically, so a format change on one side would otherwise diverge in silence and
+ /// only surface when someone tried to read a capture from the other platform.
+ func testThePairLineRendersExactlyAsItsKotlinTwinDoes() {
+ let line = SleepStager.GateTrace.runLine(
+ index: -1, startTs: 0, endTs: 0, verdict: SleepStager.GateVerdict.dropped, gate: "sparseBridgePair",
+ detail: "pair=0 gapMin=23 activeMin=21 hrInSleepBand=true reason=activeTooLong")
+ XCTAssertEqual(
+ line,
+ "gate run=-1 spanS=0 DROPPED gate=sparseBridgePair "
+ + "pair=0 gapMin=23 activeMin=21 hrInSleepBand=true reason=activeTooLong")
+ }
+
+ /// The change this constant exists for. A 45-minute interruption sits between the two bounds: over
+ /// the 30-minute default, under the 60-minute in-band one. With HR in the sleep band the whole way it
+ /// is now absorbed, where before the minute bound vetoed before HR was ever consulted.
+ ///
+ /// Field shape, not invented: log 260901-1022 had a 42-minute active run with hrInSleepBand=true
+ /// dropped as activeTooLong, and the 52-minute sleep run it would have joined then died on the
+ /// 60-minute minimum. Kotlin twin: "an active run past the default bound is absorbed when HR stays
+ /// in the sleep band".
+ func testActiveRunPastTheDefaultBoundIsAbsorbedWhenHRStaysInTheSleepBand() {
+ let mid = SleepStager.sparseBridgeActiveMaxMin * 60 + 15 * 60 // 45 min
+ let periods = [sleep(0, 3000), active(3000, 3000 + mid), sleep(3000 + mid, 9000)]
+ let out = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: calmHr(0, 9000), baseline: baseline)
+ XCTAssertEqual(out.count, 1)
+ XCTAssertEqual(out.first?.stage, "sleep")
+ XCTAssertEqual(out.first?.start, 0)
+ XCTAssertEqual(out.first?.end, 9000)
+ }
+
+ /// ...and the widened bound is not a licence. The same 45 minutes with the wearer plainly up still
+ /// fails, because in-band is the CONDITION for the wider bound, not a separate escape from it.
+ func testTheWiderBoundDoesNotApplyWhenHRIsElevated() {
+ let mid = SleepStager.sparseBridgeActiveMaxMin * 60 + 15 * 60
+ let periods = [sleep(0, 3000), active(3000, 3000 + mid), sleep(3000 + mid, 9000)]
+ let hot = calmHr(0, 3000) + calmHr(3001, 3000 + mid, bpm: 110) + calmHr(3001 + mid, 9000)
+ let out = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: hot, baseline: baseline)
+ XCTAssertEqual(out.count, 3)
+ }
+
+ /// The in-band bound may only ever widen, and it stops at `minSleepMin`.
+ func testTheInBandBoundIsAMaximumNeverAReplacement() {
+ XCTAssertGreaterThanOrEqual(SleepStager.sparseBridgeActiveMaxInBandMin,
+ SleepStager.sparseBridgeActiveMaxMin)
+ XCTAssertEqual(SleepStager.sparseBridgeActiveMaxInBandMin, SleepStager.minSleepMin)
+ }
+
+ /// Case 1 (two adjacent sleep runs, nothing between) has NO active run, so the bound it reports must
+ /// be 0 on both platforms — not the in-band 60. Computing the cap from a captured local rather than a
+ /// parameter made this report 60 for the identical decision: same behaviour, divergent trace.
+ /// Kotlin twin: "an adjacent-pair merge reports no active bound".
+ func testAnAdjacentPairMergeReportsNoActiveBound() {
+ let (_, attempts) = SleepStager.bridgeSparseSleepTraced(
+ [sleep(0, 3000), sleep(3600, 9000)],
+ sparse: true, hr: calmHr(0, 9000), baseline: baseline)
+ XCTAssertEqual(attempts.count, 1)
+ XCTAssertEqual(attempts.first?.reason, "bridged")
+ XCTAssertEqual(attempts.first?.activeMin, 0)
+ XCTAssertEqual(attempts.first?.activeCapMin, 0)
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyAnchorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyAnchorTests.swift
new file mode 100644
index 0000000000..68731afed1
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyAnchorTests.swift
@@ -0,0 +1,53 @@
+import XCTest
+import WhoopProtocol
+@testable import StrandAnalytics
+
+/// Twin of the Kotlin `SleepStagerHrOnlyAnchorTest`: what anchors the HR-only band, and why it is not
+/// the median (#1801).
+final class SleepStagerHrOnlyAnchorTests: XCTestCase {
+
+ private func window(_ aC: Int, _ aA: Int, _ aH: Int, _ nC: Int, _ nA: Int, _ nH: Int) -> [HRSample] {
+ let t0 = 1_788_300_000
+ var hr: [HRSample] = []
+ for i in 0..<(aH * 3600) { hr.append(HRSample(ts: t0 + i, bpm: aC + Int(sin(Double(i) / 500.0) * Double(aA)))) }
+ for j in 0..<(nH * 3600) {
+ hr.append(HRSample(ts: t0 + aH * 3600 + j, bpm: nC + Int(sin(Double(j) / 900.0) * Double(nA))))
+ }
+ return hr
+ }
+
+ private func sleepHours(_ hr: [HRSample], _ baseline: Double) -> Double {
+ Double(SleepStager.hrOnlySleepRuns(hr, baseline: baseline)
+ .filter { $0.stage == "sleep" }
+ .reduce(0) { $0 + ($1.end - $1.start) }) / 3600.0
+ }
+
+ func testAnchorIsTenthPercentileNearestRank() {
+ let hr = (1...100).map { HRSample(ts: 1_788_300_000 + $0, bpm: $0) }
+ XCTAssertEqual(SleepStager.hrOnlyAnchorPercentile, 0.10, accuracy: 1e-9)
+ XCTAssertEqual(SleepStager.hrOnlyBaseline(hr)!, 10.0, accuracy: 1e-9)
+ }
+
+ /// The regression this file exists for: the median anchor admits over half of any window by
+ /// definition, and measured 14.4 h against a truth of 8 on a field-shaped day.
+ func testMedianOverDetectsAndPercentileDoesNot() {
+ let hr = window(74, 11, 16, 64, 5, 8)
+ let median = sleepHours(hr, SleepStager.hrBaseline(hr)!)
+ let percentile = sleepHours(hr, SleepStager.hrOnlyBaseline(hr)!)
+ XCTAssertGreaterThan(median, 13.0)
+ XCTAssertTrue((7.0...9.5).contains(percentile), "was \(percentile)")
+ XCTAssertLessThan(percentile, median - 4.0)
+ }
+
+ /// Errs toward UNDER-detection: a missed night leaves "No data", an invented one shows a wrong Rest.
+ func testLongMultiNightWindowIsUnderReadNotOverRead() {
+ let h = sleepHours(window(78, 12, 38, 60, 6, 16), SleepStager.hrOnlyBaseline(window(78, 12, 38, 60, 6, 16))!)
+ XCTAssertLessThanOrEqual(h, 16.0, "was \(h)")
+ XCTAssertGreaterThanOrEqual(h, 6.0, "was \(h)")
+ }
+
+ func testDetectorBandIsASeparateConstantFromTheConfirmationGate() {
+ XCTAssertEqual(SleepStager.hrOnlyBandMult, 1.05, accuracy: 1e-9)
+ XCTAssertEqual(SleepStager.hrSleepBandMult, 1.05, accuracy: 1e-9)
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift
new file mode 100644
index 0000000000..df2182062e
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySessionsTests.swift
@@ -0,0 +1,81 @@
+import Foundation
+import XCTest
+import WhoopProtocol
+@testable import StrandAnalytics
+
+/// Whole HR-only sessions (#1801) and the physiology they report (#1884).
+///
+/// The ORACLE twin of the Kotlin `SleepStagerHrOnlySessionsTest`: the same synthetic night and the same
+/// expectations, so a divergence in the null contract fails on one platform and not the other. The Apple
+/// side had no twin of this file until #1884, which is exactly how the two `restingHR`/`avgHRV` spellings
+/// could have drifted apart unnoticed — Swift structs have no `copy()`, so each rebuild respells the
+/// field list and a field is lost by omission rather than by an edit anyone reviews.
+///
+/// The night is synthetic: a slow HR drift well under the window median, so the spine puts it in the
+/// sleep band. That exercises assembly and the reporting contract; it is NOT a claim that the staging is
+/// accurate on a real night, which no test here can establish.
+final class SleepStagerHrOnlySessionsTests: XCTestCase {
+
+ /// A field-shaped window: `aH` hours awake around 74 +/- 11 bpm, then `nH` hours asleep around
+ /// 64 +/- 5 — the shape the #1801 report shows, and the same generator the Kotlin twin uses.
+ private func window(aH: Int = 16, nH: Int = 8) -> ([HRSample], [RRInterval]) {
+ let t0 = 1_788_300_000
+ var hr: [HRSample] = []
+ var rr: [RRInterval] = []
+ for i in 0..<(aH * 3600) {
+ let bpm = 74 + Int(sin(Double(i) / 500.0) * 11)
+ hr.append(HRSample(ts: t0 + i, bpm: bpm))
+ rr.append(RRInterval(ts: t0 + i, rrMs: 60000 / bpm))
+ }
+ for j in 0..<(nH * 3600) {
+ let bpm = 64 + Int(sin(Double(j) / 900.0) * 5)
+ let t = t0 + aH * 3600 + j
+ hr.append(HRSample(ts: t, bpm: bpm))
+ rr.append(RRInterval(ts: t, rrMs: 60000 / bpm))
+ }
+ return (hr, rr)
+ }
+
+ func testLowHRNightBecomesAtLeastOneStagedSession() {
+ let (hr, rr) = window()
+ let out = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: [])
+ XCTAssertFalse(out.isEmpty, "expected at least one night, got \(out.count)")
+ XCTAssertTrue(out.allSatisfy { !$0.stages.isEmpty }, "every session must carry stages")
+ XCTAssertTrue(out.allSatisfy { ($0.end - $0.start) >= SleepStager.minSleepMin * 60 },
+ "every session must span at least minSleepMin")
+ // Conservative by design: it may under-read a night, but must never invent more sleep than the
+ // window holds.
+ XCTAssertLessThanOrEqual(out.reduce(0) { $0 + ($1.end - $1.start) }, 8 * 3600,
+ "total must not exceed the 8 h actually asleep")
+ }
+
+ /// #1884 reversed the null contract #1801 established, so the reasoning is worth keeping beside it.
+ ///
+ /// #1801 withheld `restingHR` and `avgHRV` here on the grounds that a baseline is the one thing a
+ /// false positive cannot be unwound from. The field logs showed the withholding was the more damaging
+ /// error: the values are MEASURED, not inferred. The BOUNDS are what heart rate infers — which is why
+ /// the session still marks itself `hrOnly` — but each RMSSD is computed over its own 5-minute window,
+ /// so fuzzy bounds change WHICH windows are included, not whether any one of them is valid. Resting HR
+ /// is HR-derived, and an HR-only night is precisely the night with plenty of HR.
+ ///
+ /// The marker travels with the session, so a consumer that wants to weigh these down still can.
+ func testHrOnlySessionReportsMeasuredRestingHRAndHRVAndStillMarksItself() throws {
+ let (hr, rr) = window()
+ let s = try XCTUnwrap(SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: []).first)
+ XCTAssertTrue(s.hrOnly, "must still be flagged hrOnly")
+ XCTAssertNotNil(s.restingHR, "restingHR is HR-derived and must be reported")
+ XCTAssertNotNil(s.avgHRV, "avgHRV must be reported when R-R is present")
+ }
+
+ /// The honest boundary of the change: reporting is driven by whether the INPUT exists, not by the
+ /// `hrOnly` flag. With no R-R there is nothing to compute an RMSSD from, so HRV is still absent — and
+ /// resting HR, which needs only HR, is still reported. A regression that re-blanked HRV wholesale
+ /// would pass the case above if it also happened to blank on missing R-R; this separates them.
+ func testHrOnlySessionWithoutRRStillReportsRestingHR() throws {
+ let (hr, _) = window()
+ let s = try XCTUnwrap(SleepStager.hrOnlySessions(hr: hr, rr: [], resp: []).first)
+ XCTAssertTrue(s.hrOnly, "must still be flagged hrOnly")
+ XCTAssertNotNil(s.restingHR, "restingHR needs only HR")
+ XCTAssertNil(s.avgHRV, "no R-R means no RMSSD to report")
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift
new file mode 100644
index 0000000000..5278d38cdf
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift
@@ -0,0 +1,81 @@
+import XCTest
+import WhoopProtocol
+@testable import StrandAnalytics
+
+/// The HR-only Stage-0 spine (#1801), for a strap that streams heart rate but banks no motion.
+///
+/// These cases are the ORACLE for the Kotlin twin `SleepStagerHrOnlySpineTest`: the same inputs and the
+/// same expected runs are asserted on both sides, so a divergence in the flag rule, the epoch bucketing
+/// or the run construction fails on one platform and not the other.
+///
+/// Baseline 60 throughout, so the sleep band is `60 * hrSleepBandMult` = 63.0 bpm inclusive. Samples are
+/// laid down every 10 s, six to a 60 s epoch.
+final class SleepStagerHrOnlySpineTests: XCTestCase {
+
+ /// Six samples in each epoch, `bpm` per epoch index.
+ private func hr(from epoch: Int, _ bpms: [Int]) -> [HRSample] {
+ var out: [HRSample] = []
+ for (i, bpm) in bpms.enumerated() {
+ let base = (epoch + i) * 60
+ for k in 0..<6 { out.append(HRSample(ts: base + k * 10, bpm: bpm)) }
+ }
+ return out
+ }
+
+ private func tuples(_ p: [SleepStager.Period]) -> [String] {
+ p.map { "\($0.stage) \($0.start)-\($0.end)" }
+ }
+
+ func testNoBaselineOrNoHrYieldsNothing() {
+ XCTAssertTrue(SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]), baseline: nil).isEmpty)
+ XCTAssertTrue(SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]), baseline: 0).isEmpty)
+ XCTAssertTrue(SleepStager.hrOnlySleepRuns([], baseline: 60).isEmpty)
+ }
+
+ func testAllInBandIsOneSleepRun() {
+ let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 55]), baseline: 60)
+ XCTAssertEqual(tuples(runs), ["sleep 60000-60170"])
+ }
+
+ func testAllOutOfBandIsOneActiveRun() {
+ let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [90, 90, 90]), baseline: 60)
+ XCTAssertEqual(tuples(runs), ["active 60000-60170"])
+ }
+
+ func testSleepActiveSleepSegmentsIntoThree() {
+ let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 90, 55, 55]), baseline: 60)
+ XCTAssertEqual(tuples(runs), ["sleep 60000-60110", "active 60120-60170", "sleep 60180-60290"])
+ }
+
+ /// A gap wider than `maxGapMin` breaks a run even when the class never changes — the same rule
+ /// `buildRuns` applies to a gravity gap.
+ func testGapWiderThanMaxGapBreaksASameClassRun() {
+ let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]) + hr(from: 1030, [55]), baseline: 60)
+ XCTAssertEqual(tuples(runs), ["sleep 60000-60050", "sleep 61800-61850"])
+ }
+
+ /// The band test is a MEDIAN, so one arousal spike inside an epoch cannot flip it to active — the
+ /// property `hrSleepBandAcross` documents, asserted here on the epoch reduction.
+ func testOneSpikeInAnEpochDoesNotFlipIt() {
+ var samples = hr(from: 1000, [55])
+ samples[5] = HRSample(ts: samples[5].ts, bpm: 190)
+ XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(samples, baseline: 60)), ["sleep 60000-60050"])
+ }
+
+ /// A run's `end` is the last SAMPLE seen, not the final epoch's start. Pinned because reading the
+ /// epoch start reports every run one epoch short of the data it covers — silently, and straight into
+ /// the caller's minimum-duration gate. Three 60 s epochs of samples laid every 10 s end at 60170.
+ func testRunEndIsTheLastSampleNotTheEpochStart() {
+ let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 55]), baseline: 60)
+ XCTAssertEqual(runs.first?.end, 60170)
+ XCTAssertEqual((runs.first!.end - runs.first!.start), 170)
+ }
+
+ /// The band is inclusive: 63 is `60 * 1.05` exactly.
+ func testBandBoundaryIsInclusive() {
+ XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(hr(from: 1000, [63]), baseline: 60)),
+ ["sleep 60000-60050"])
+ XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(hr(from: 1000, [64]), baseline: 60)),
+ ["active 60000-60050"])
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyTraceTests.swift
new file mode 100644
index 0000000000..a219d301b8
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyTraceTests.swift
@@ -0,0 +1,57 @@
+import XCTest
+import WhoopProtocol
+@testable import StrandAnalytics
+
+/// Twin of the Kotlin `SleepStagerHrOnlyTraceTest`. The expected strings are byte-identical on both
+/// platforms on purpose — these lines exist to be read side by side.
+final class SleepStagerHrOnlyTraceTests: XCTestCase {
+
+ func testLineNamesTheDerivedThresholdAndLongestCandidate() {
+ XCTAssertEqual(
+ SleepStager.GateTrace.hrOnlyLine(anchorBpm: 61.0, bandBpm: 64.05, hrP50: 74.0, hrP90: 88.0, epochs: 3021, runs: 48,
+ mergedRuns: 12, sleepRuns: 7, longestSleepMin: 41,
+ staged: 0, kept: 0, minSleepMin: 60),
+ "[sleep] hr-only spine anchorBpm=61.0 bandBpm=64.1 hrP50=74.0 hrP90=88.0 "
+ + "epochs=3021 runs=48 merged=12 "
+ + "sleepRuns=7 longestMin=41 staged=0 kept=0 minSleepMin=60"
+ )
+ }
+
+ func testAbsentAnchorPrintsNilRatherThanZero() {
+ let line = SleepStager.GateTrace.hrOnlyLine(anchorBpm: nil, bandBpm: nil, hrP50: nil, hrP90: nil, epochs: 0, runs: 0,
+ mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0,
+ staged: 0, kept: 0, minSleepMin: 60)
+ XCTAssertTrue(line.contains("anchorBpm=nil bandBpm=nil"))
+ }
+
+ /// `epochs` counts EPOCHS, not samples — the axis every other number is measured on. Twin of the
+ /// Kotlin test, which guards a bug I actually made: the first version reported the HR sample count.
+ func testEpochsCountsEpochsNotSamples() {
+ var lines: [String] = []
+ // 10 epochs x 6 samples = 60 samples.
+ var hr: [HRSample] = []
+ for i in 0..<10 {
+ let base = (1000 + i) * 60
+ for k in 0..<6 { hr.append(HRSample(ts: base + k * 10, bpm: 120)) }
+ }
+ _ = SleepStager.hrOnlySessions(hr: hr, rr: [], resp: [], traceSink: { lines.append($0) })
+ XCTAssertEqual(lines.count, 1, "exactly one funnel line per call")
+ XCTAssertTrue(lines[0].contains("epochs=10 "), "got: \(lines[0])")
+ }
+
+ /// The rounding is ARITHMETIC, not `printf`. A harness caught `String(format: "%.1f", 64.05)`
+ /// giving 64.0 on Apple against Java's 64.1 — a divergence that `anchor * 1.05` would have hit
+ /// constantly. These are the values that harness compared.
+ func testOneDecimalRoundingMatchesTheKotlinTwin() {
+ let cases: [(Double, String)] = [
+ (64.05, "64.1"), (61.0, "61.0"), (77.7, "77.7"), (66.15, "66.2"),
+ (71.4, "71.4"), (1.05, "1.1"), (0.0, "0.0"), (120.0, "120.0"),
+ ]
+ for (v, expected) in cases {
+ let line = SleepStager.GateTrace.hrOnlyLine(anchorBpm: v, bandBpm: nil, hrP50: nil, hrP90: nil, epochs: 0, runs: 0,
+ mergedRuns: 0, sleepRuns: 0, longestSleepMin: 0,
+ staged: 0, kept: 0, minSleepMin: 60)
+ XCTAssertTrue(line.contains("anchorBpm=\(expected) "), "\(v) -> expected \(expected), got: \(line)")
+ }
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift
index 6ea4bcff3f..aac9f9147c 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift
@@ -859,6 +859,163 @@ final class SleepStagerTests: XCTestCase {
XCTAssertLessThan(hrv!, 50, "ectopic spikes must be rejected before rMSSD")
}
+ // MARK: - Session window endpoint: the closed [start, end] window has a closed FINAL bin
+
+ // `sessionRestingHR` and `sessionHrvWindows` prefilter ts ∈ [start, end], so a sample sitting
+ // exactly ON `end` is admitted. Binned as [t, t + 300) with a loop that stops at `end`, an
+ // ALIGNED end (end - start a multiple of the window) left that admitted sample in no bin: it
+ // counted as data and was then silently ignored. The final bin is therefore closed on `end`,
+ // the same rule RecoveryScorer already carries. Each case is paired with a non-aligned twin
+ // that was always correct, so the fix cannot be mistaken for a change to trailing partial bins.
+ // Expected values are the verbatim stdout of a standalone Swift oracle over the fixed binning;
+ // SleepStagerWindowEndpointTest.kt pins the same scenarios with the same literals.
+
+ func testSessionRestingHRAlignedEndpointSampleReachesABin() {
+ // Minimal case: start = 0, end = 300 (one window wide), five samples on the endpoint.
+ // Already returned 60 before the fix, but only via the all-sample fallback (no bin held
+ // anything); it now flows through the bin path.
+ let hr = (0..<5).map { _ in HRSample(ts: 300, bpm: 60) }
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 300, hr: hr), 60,
+ "a sample exactly on an aligned end must belong to the final bin")
+ }
+
+ func testSessionRestingHRNonAlignedEndpointSampleReachesABin() {
+ // Paired non-aligned case: end = 450 falls mid-window, so the endpoint sample was always
+ // inside the trailing partial bin. Same answer, before and after.
+ let hr = (0..<5).map { _ in HRSample(ts: 450, bpm: 60) }
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 450, hr: hr), 60,
+ "a sample on a non-aligned end stays in the trailing partial bin")
+ }
+
+ func testSessionRestingHREndpointSampleLandsInFinalBinOnly() {
+ // Where the fallback cannot hide the dropped sample: a dense 70 bpm first bin plus five
+ // endpoint beats at 40 makes the floor 40, and the 70 bpm bin stays untouched. Before the
+ // fix the endpoint beats vanished and the shipped resting HR read 70.
+ var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 70) }
+ hr.append(contentsOf: (0..<5).map { _ in HRSample(ts: 600, bpm: 40) })
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 40,
+ "the endpoint sample belongs to the final bin only")
+ }
+
+ func testSessionRestingHRDegenerateZeroLengthWindow() {
+ // start == end: the single instant is one closed bin — the same number the all-sample
+ // fallback produced before.
+ let hr = (0..<5).map { _ in HRSample(ts: 1000, bpm: 58) }
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 1000, end: 1000, hr: hr), 58)
+ }
+
+ func testSessionRestingHREndpointSweepMatchesOracle() {
+ // `end` walks a whole window width and beyond in 25 s steps against a dense 70 bpm opening
+ // bin plus five endpoint beats at 60. Verbatim oracle stdout, the check that catches drift
+ // the eye does not — including end = 300, where the single closed bin holds BOTH groups.
+ let expected = [62, 68, 69, 69, 70, 70, 70, 70, 70, 70, 70, 70, 70,
+ 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60,
+ 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60]
+ var i = 0
+ var endTs = 0
+ while endTs <= 900 {
+ var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 70) }
+ hr.append(contentsOf: (0..<5).map { _ in HRSample(ts: endTs, bpm: 60) })
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: endTs, hr: hr), expected[i],
+ "sessionRestingHR.end=\(endTs)")
+ i += 1
+ endTs += 25
+ }
+ }
+
+ // MARK: - #1943: sessionRestingHR bin-population + plausibility gates
+
+ // A one-sample bin at a dropout bpm would have won the floor before the gate. The gate excludes
+ // it (winnerN < minBinSamples), and the floor falls back to the next-best qualifying bin.
+ func testSessionRestingHRThinBinCannotWinTheFloor() {
+ // 300 samples at 60 in the first bin (qualifies), one sample at 38 in the final bin (thin).
+ // Before #1943 the floor was 38; now the thin bin is excluded and the floor is 60.
+ var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 60) }
+ hr.append(HRSample(ts: 600, bpm: 38))
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 60,
+ "a one-sample bin cannot win the floor under the #1943 gate")
+ }
+
+ // A sub-physiological mean (below 25 bpm) is excluded even when the bin is well-populated, so a
+ // dropout artefact sustained across several samples cannot become the resting HR either.
+ func testSessionRestingHRImplausibleBinCannotWinTheFloor() {
+ // 300 samples at 60 (qualifies), then 300 samples at 20 (well-populated but implausible).
+ // Before #1943 the floor was 20; now the implausible bin is excluded and the floor is 60.
+ let hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 60) }
+ + (300..<600).map { HRSample(ts: $0, bpm: 20) }
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 60,
+ "a sub-25 bpm bin cannot win the floor under the #1943 gate")
+ }
+
+ // When every bin is thin or implausible, the gate falls back to the lowest of ALL bin means
+ // (ungated), preserving the never-null-on-data behaviour. The floor is the same as before the
+ // gate existed.
+ func testSessionRestingHRAllBinsGatedFallsBackToUngatedMin() {
+ // Two bins, each with one sample: 40 and 50. Neither qualifies (both thin). The fallback
+ // is the min of all bin means = 40, the same value the ungated path produced.
+ let hr: [HRSample] = [HRSample(ts: 0, bpm: 40), HRSample(ts: 300, bpm: 50)]
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 40,
+ "when no bin qualifies, the floor falls back to the ungated min")
+ }
+
+ // A dense, ordinary night (every bin well-populated and plausible) is unchanged by the gate.
+ func testSessionRestingHRDenseNightUnchangedByGate() {
+ let hr: [HRSample] = (0..<1800).map { HRSample(ts: $0, bpm: 60) }
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 1800, hr: hr), 60,
+ "a well-populated night is unchanged by the gate")
+ }
+
+ // The gate's threshold is exactly 5 samples: a bin with 5 qualifies, a bin with 4 does not.
+ func testSessionRestingHRBinSampleCountBoundary() {
+ // First bin: 300 samples at 70 (qualifies). Final bin: 4 samples at 40 (below threshold).
+ // The 4-sample bin is excluded; the floor is 70.
+ var hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 70) }
+ hr.append(contentsOf: (0..<4).map { _ in HRSample(ts: 600, bpm: 40) })
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 70,
+ "a 4-sample bin is below the minBinSamples=5 threshold and cannot win")
+ }
+
+ // The plausibility gate's threshold is exactly 25 bpm: a mean of 25 qualifies, 24.9 does not.
+ func testSessionRestingHRPlausibilityBoundary() {
+ // First bin: 300 samples at 60 (qualifies). Final bin: 300 samples at 24 (implausible).
+ // The 24-bpm bin is excluded; the floor is 60.
+ let hr: [HRSample] = (0..<300).map { HRSample(ts: $0, bpm: 60) }
+ + (300..<600).map { HRSample(ts: $0, bpm: 24) }
+ XCTAssertEqual(SleepStager.sessionRestingHR(start: 0, end: 600, hr: hr), 60,
+ "a 24-bpm mean is below the minPlausibleBpm=25 threshold and cannot win")
+ }
+
+ func testSessionHrvWindowsAlignedEndpointBeatsFillTheFinalWindow() {
+ // 120 beats in the opening window plus three beats exactly on an aligned end = 600. The
+ // second window is the final one, so it closes on `end` and holds those three beats;
+ // before the fix it was emitted empty and its RMSSD was nil. Constant 900 ms RR keeps the
+ // RMSSD analytically 0.0 on both platforms — this pins the BINNING, not the RMSSD math.
+ var rr: [RRInterval] = (0..<120).map { RRInterval(ts: $0, rrMs: 900) }
+ rr.append(contentsOf: (0..<3).map { _ in RRInterval(ts: 600, rrMs: 900) })
+ let wins = SleepStager.sessionHrvWindows(start: 0, end: 600, rr: rr, stages: [])
+ XCTAssertEqual(wins.map { $0.startTs }, [0, 300])
+ XCTAssertEqual(wins.map { $0.cleanBeats }, [120, 3],
+ "the endpoint beats must fill the final window")
+ XCTAssertEqual(wins.map { $0.rmssd }, [0.0, 0.0])
+ }
+
+ func testSessionHrvWindowsNonAlignedEndpointUnchanged() {
+ // Paired non-aligned case: end = 450 sits mid-window, so the endpoint beats were already
+ // inside the trailing partial window. Same shape, before and after.
+ var rr: [RRInterval] = (0..<120).map { RRInterval(ts: $0, rrMs: 900) }
+ rr.append(contentsOf: (0..<3).map { _ in RRInterval(ts: 450, rrMs: 900) })
+ let wins = SleepStager.sessionHrvWindows(start: 0, end: 450, rr: rr, stages: [])
+ XCTAssertEqual(wins.map { $0.startTs }, [0, 300])
+ XCTAssertEqual(wins.map { $0.cleanBeats }, [120, 3])
+ }
+
+ func testSessionAvgHRVZeroLengthWindowUsesTheEndpointBeats() {
+ // The value-level consequence: a zero-length window (start == end) is one closed bin, so
+ // beats admitted by the prefilter produce a number instead of nil.
+ let rr = (0..<3).map { _ in RRInterval(ts: 1000, rrMs: 900) }
+ XCTAssertEqual(SleepStager.sessionAvgHRV(start: 1000, end: 1000, rr: rr), 0.0)
+ }
+
// MARK: - Helper robustness
func testConvolveReflectShortInputDoesNotCrash() {
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SlidingStreamWindowTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SlidingStreamWindowTests.swift
new file mode 100644
index 0000000000..5c809559a4
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SlidingStreamWindowTests.swift
@@ -0,0 +1,124 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// Pins the sliding read buffer against the only contract that matters: for every window, it returns
+/// EXACTLY what a direct store read would have returned. Twin of Kotlin `SlidingStreamWindowTest`.
+///
+/// The test store is a plain sorted array, so "what a direct read would have returned" is computable
+/// independently rather than asserted from the implementation's own behaviour — otherwise the test would
+/// agree with a wrong splice.
+final class SlidingStreamWindowTests: XCTestCase {
+
+ private let day = 86_400
+ private let h30 = 108_000
+ private let midnight = 1_787_875_200
+
+ private lazy var store: [Int] = Array(stride(from: midnight - 5 * day, through: midnight + day, by: 1))
+
+ private func direct(_ from: Int, _ to: Int, limit: Int = 1_000_000) -> [Int] {
+ Array(store.filter { $0 >= from && $0 <= to }.prefix(limit))
+ }
+
+ private func window(limit: Int = 1_000_000) -> SlidingStreamWindow {
+ SlidingStreamWindow(tsOf: { $0 }, limit: limit) { [self] _, f, t in direct(f, t, limit: limit) }
+ }
+
+ /// Day 0 reads its whole 54 h window; each later day reads exactly one 24 h stride. Without the buffer
+ /// this walk reads 5 x 54 h; the union of all five windows is what it reads instead.
+ func testBackwardWalkMatchesDirectReadsAndReadsEachRowOnce() async {
+ let w = window()
+ for offset in 0..<5 {
+ let dayStart = midnight - offset * day
+ let from = dayStart - h30, to = dayStart + day
+ let got = await w.rows(owner: "owner", from: from, to: to)
+ XCTAssertEqual(got, direct(from, to), "window \(offset) must equal a direct read")
+ }
+ let lastFrom = midnight - 4 * day - h30
+ XCTAssertEqual(w.rowsRead, direct(lastFrom, midnight + day).count, "each row read exactly once")
+ XCTAssertGreaterThan(w.rowsServed, w.rowsRead / 2, "and most rows came from the buffer")
+ }
+
+ func testOwnerFlipFallsBackToADirectRead() async {
+ let w = window()
+ let from = midnight - h30, to = midnight + day
+ _ = await w.rows(owner: "a", from: from, to: to)
+ let readAfterFirst = w.rowsRead
+ let got = await w.rows(owner: "b", from: from - day, to: to - day)
+ XCTAssertEqual(got, direct(from - day, to - day))
+ // A different strap cannot reuse the buffer, so this is a full window read, not a stride.
+ XCTAssertEqual(w.rowsRead - readAfterFirst, to - from + 1)
+ }
+
+ /// A truncated read cannot be sliced, because `ORDER BY ts ASC LIMIT` drops the NEWEST rows — the
+ /// buffer would be missing its tail with nothing to say so.
+ /// The truncation counter is the one diagnostic here that means a SCORE may be wrong rather than
+ /// merely slow, so pin when it moves and when it does not. Twin of the Kotlin case of the same name.
+ func testTruncatedReadsCountsOnlyReadsThatLostRows() async {
+ let clean = window()
+ _ = await clean.rows(owner: "owner", from: midnight - h30, to: midnight + day)
+ XCTAssertEqual(clean.truncatedReads, 0, "a read under the cap lost nothing")
+
+ let capped = window(limit: 1_000)
+ _ = await capped.rows(owner: "owner", from: midnight - h30, to: midnight + day)
+ XCTAssertEqual(capped.truncatedReads, 1, "a read AT the cap dropped its newest rows")
+ }
+
+ /// Once a read is truncated the planner refuses to splice at all — `cachedTruncated` is its FIRST
+ /// guard — so every later window is a fresh full read. Each of those that is itself at the cap is a
+ /// separate lost tail and counts again: the number is windows-that-lost-rows, not passes.
+ ///
+ /// Deliberately NOT a test of the truncated-extension branch. That branch needs a buffer that is
+ /// valid but whose extension overruns the cap, and a truncated read can never leave a valid buffer,
+ /// so a uniform backward walk cannot reach it.
+ func testEachTruncatedWindowCountsSeparately() async {
+ let w = window(limit: 1_000)
+ _ = await w.rows(owner: "owner", from: midnight - h30, to: midnight + day)
+ let afterFirst = w.truncatedReads
+ _ = await w.rows(owner: "owner", from: midnight - day - h30, to: midnight)
+ XCTAssertEqual(w.truncatedReads, afterFirst + 1, "one increment per window that lost rows")
+ }
+
+ func testTruncatedReadIsNeverSliced() async {
+ let limit = 1_000
+ let w = window(limit: limit)
+ let from = midnight - h30, to = midnight + day
+ let first = await w.rows(owner: "owner", from: from, to: to)
+ XCTAssertEqual(first.count, limit)
+ let next = await w.rows(owner: "owner", from: from - day, to: to - day)
+ XCTAssertEqual(next, direct(from - day, to - day, limit: limit))
+ }
+
+ /// A gap (the day cache skipped a day) must not splice across the hole.
+ func testGapDoesNotSpliceAcrossTheMissingDay() async {
+ let w = window()
+ _ = await w.rows(owner: "owner", from: midnight - h30, to: midnight + day)
+ let skipStart = midnight - 2 * day
+ let got = await w.rows(owner: "owner", from: skipStart - h30, to: skipStart + day)
+ XCTAssertEqual(got, direct(skipStart - h30, skipStart + day))
+ }
+
+ /// A FAILED read must not be cached as if it were an empty range. Twin of Kotlin
+ /// `failedReadIsNotCachedAsEmpty`.
+ ///
+ /// This is the one that bites silently. An empty SUCCESSFUL read is a true statement — there are no
+ /// rows in that span — so the next day may splice against it. An empty FAILED read says nothing, and
+ /// caching it lets the following day splice against rows nobody fetched: its window would come back
+ /// missing everything the buffer claimed to hold, with no error and no log line, and the days built
+ /// from it would be scored on inputs that quietly lost hours.
+ ///
+ /// Reachable on THIS platform in particular: the engine wraps its store reads in `try?`, so a failure
+ /// arrives as an empty array unless the reader says otherwise.
+ func testFailedReadIsNotCachedAsEmpty() async {
+ var failNext = true
+ let w = SlidingStreamWindow(tsOf: { $0 }, limit: 1_000_000) { [self] _, f, t in
+ if failNext { failNext = false; return nil }
+ return direct(f, t)
+ }
+ let from = midnight - h30, to = midnight + day
+ let first = await w.rows(owner: "owner", from: from, to: to)
+ XCTAssertEqual(first, [], "a failed read yields nothing, as it did before the buffer existed")
+ // The next day must go back to the store, not splice against a window nobody filled.
+ let next = await w.rows(owner: "owner", from: from - day, to: to - day)
+ XCTAssertEqual(next, direct(from - day, to - day))
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift
index 2a5f5f73ae..b42ddd4edc 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SparseBridgeAttemptTests.swift
@@ -23,7 +23,7 @@ final class SparseBridgeAttemptTests: XCTestCase {
/// must equal the reduction in sleep-run count the REAL bridge produces.
private func assertAgreesWithBridge(_ periods: [SleepStager.Period], hr: [HRSample],
baseline: Double?, file: StaticString = #filePath, line: UInt = #line) {
- let attempts = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: baseline)
+ let attempts = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: baseline).1
let before = periods.filter { $0.stage == "sleep" }.count
let after = SleepStager.bridgeSparseSleep(periods, sparse: true, hr: hr, baseline: baseline)
.filter { $0.stage == "sleep" }.count
@@ -35,7 +35,7 @@ final class SparseBridgeAttemptTests: XCTestCase {
// Two sleep runs 10 min apart, HR asleep across the gap → bridged.
let periods = [sleep(0, 3_600), sleep(4_200, 8_000)]
let hr = sleepHR(from: 0, to: 8_000)
- let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
+ let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70).1
XCTAssertEqual(a.count, 1)
XCTAssertEqual(a[0].gapMin, 10)
XCTAssertTrue(a[0].bridged)
@@ -48,7 +48,7 @@ final class SparseBridgeAttemptTests: XCTestCase {
let gap = (SleepStager.sparseBridgeGapMin + 30) * 60
let periods = [sleep(0, 3_600), sleep(3_600 + gap, 3_600 + gap + 3_600)]
let hr = sleepHR(from: 0, to: 3_600 + gap + 3_600)
- let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
+ let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70).1
XCTAssertEqual(a.count, 1)
XCTAssertFalse(a[0].bridged)
XCTAssertEqual(a[0].reason, "gapTooLong")
@@ -60,7 +60,7 @@ final class SparseBridgeAttemptTests: XCTestCase {
// Gap is short enough, but HR across it is clearly awake → the HR condition is what refused.
let periods = [sleep(0, 3_600), sleep(4_200, 8_000)]
let hr = sleepHR(from: 0, to: 8_000, bpm: 110)
- let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 60)
+ let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 60).1
XCTAssertEqual(a.count, 1)
XCTAssertFalse(a[0].bridged)
XCTAssertFalse(a[0].hrInSleepBand)
@@ -68,21 +68,28 @@ final class SparseBridgeAttemptTests: XCTestCase {
assertAgreesWithBridge(periods, hr: hr, baseline: 60)
}
- /// The reporter's shape (#737): fragments separated by ACTIVE runs are never adjacent sleep pairs, so
- /// the bridge considers nothing — which is exactly why their log showed runsBefore == runsAfter with
- /// no explanation. An EMPTY attempt list is the diagnosis, and the emitter says so in words.
- func testFragmentsSeparatedByActiveRunsProduceNoAttempts() {
+ /// REPINNED for #1657. This used to assert the opposite — "an intervening active run is never a
+ /// considered pair" — which was true, and was the bug: the shape it describes is the reporter's
+ /// bathroom break, and the bridge built to rescue fragmentation was structurally unable to reach it.
+ /// A field trace then found the bridge merging nothing on 14 of 14 sparse nights for this reason.
+ ///
+ /// The test was correct when written and quietly became a guard on the wrong behaviour. Kept pointing
+ /// at the same shape so the history reads straight.
+ func testAShortActiveRunBetweenFragmentsIsNowConsideredAndBridged() {
let periods = [sleep(0, 3_000), active(3_000, 3_300), sleep(3_300, 6_000)]
let hr = sleepHR(from: 0, to: 6_000)
- let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
- XCTAssertTrue(a.isEmpty, "an intervening active run is never a considered pair")
- assertAgreesWithBridge(periods, hr: hr, baseline: 70) // and the real bridge merges nothing
+ let (out, a) = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70)
+ XCTAssertEqual(a.count, 1, "the pair is now considered")
+ XCTAssertEqual(a.first?.reason, "bridged")
+ XCTAssertEqual(a.first?.activeMin, 5)
+ XCTAssertEqual(out.count, 1, "and the night is one run again")
+ XCTAssertEqual(out.first?.end, 6_000)
}
func testNoOpWhenNotSparse() {
let periods = [sleep(0, 3_600), sleep(4_200, 8_000)]
let hr = sleepHR(from: 0, to: 8_000)
- XCTAssertTrue(SleepStager.sparseBridgeAttempts(periods, sparse: false, hr: hr, baseline: 70).isEmpty,
+ XCTAssertTrue(SleepStager.bridgeSparseSleepTraced(periods, sparse: false, hr: hr, baseline: 70).1.isEmpty,
"the dense 4.0 path is untouched, so there is nothing to explain")
}
@@ -91,7 +98,7 @@ final class SparseBridgeAttemptTests: XCTestCase {
func testChainOfThreeReportsTwoPairsAndAgrees() {
let periods = [sleep(0, 3_600), sleep(4_200, 8_000), sleep(8_600, 12_000)]
let hr = sleepHR(from: 0, to: 12_000)
- let a = SleepStager.sparseBridgeAttempts(periods, sparse: true, hr: hr, baseline: 70)
+ let a = SleepStager.bridgeSparseSleepTraced(periods, sparse: true, hr: hr, baseline: 70).1
XCTAssertEqual(a.count, 2)
XCTAssertTrue(a.allSatisfy { $0.bridged })
assertAgreesWithBridge(periods, hr: hr, baseline: 70)
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SseDeltasTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SseDeltasTests.swift
new file mode 100644
index 0000000000..8ac0b6864e
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SseDeltasTests.swift
@@ -0,0 +1,231 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// Byte-identity pin for the SSE delta parser. The same SSE lines MUST produce the same text as
+/// the Android twin `com.noop.analytics.SseDeltasTest`. Cross-platform parity is the contract.
+final class SseDeltasTests: XCTestCase {
+
+ // MARK: - SSE line splitting
+
+ func testStripsDataPrefix() {
+ XCTAssertEqual(SseDeltas.dataPayload(fromLine: "data: {\"hello\":1}"), "{\"hello\":1}")
+ }
+
+ func testStripsDataPrefixNoSpace() {
+ XCTAssertEqual(SseDeltas.dataPayload(fromLine: "data:{\"hello\":1}"), "{\"hello\":1}")
+ }
+
+ func testBlankLineReturnsNil() {
+ XCTAssertNil(SseDeltas.dataPayload(fromLine: ""))
+ XCTAssertNil(SseDeltas.dataPayload(fromLine: " "))
+ }
+
+ func testCommentLineReturnsNil() {
+ XCTAssertNil(SseDeltas.dataPayload(fromLine: ": keep-alive"))
+ }
+
+ func testNonDataFieldReturnsNil() {
+ XCTAssertNil(SseDeltas.dataPayload(fromLine: "event: content_block_delta"))
+ XCTAssertNil(SseDeltas.dataPayload(fromLine: "id: 12345"))
+ XCTAssertNil(SseDeltas.dataPayload(fromLine: "retry: 1000"))
+ }
+
+ func testIsDone() {
+ XCTAssertTrue(SseDeltas.isDone("[DONE]"))
+ XCTAssertTrue(SseDeltas.isDone(" [DONE] "))
+ XCTAssertFalse(SseDeltas.isDone("{\"choices\":[]}"))
+ }
+
+ // MARK: - CRLF parity (Kotlin trim() strips \r; Swift .whitespaces does not)
+
+ func testIsDoneStripsCRLF() {
+ // A CRLF stream leaves a trailing \r after \n-splitting. Kotlin's trim() removes it;
+ // Swift must use .whitespacesAndNewlines to match, or isDone returns false on iOS.
+ XCTAssertTrue(SseDeltas.isDone("[DONE]\r"))
+ XCTAssertTrue(SseDeltas.isDone("[DONE]\r\n"))
+ }
+
+ func testDataPayloadStripsCRLF() {
+ XCTAssertEqual(SseDeltas.dataPayload(fromLine: "data: {\"hello\":1}\r"), "{\"hello\":1}")
+ }
+
+ // MARK: - OpenAI delta
+
+ func testOpenAiContentDelta() {
+ let payload = #"{"choices":[{"index":0,"delta":{"content":"Hello"}}]}"#
+ XCTAssertEqual(SseDeltas.openAiDelta(payload), "Hello")
+ }
+
+ func testOpenAiRoleDeltaReturnsNil() {
+ // The first chunk often carries only the role, no content.
+ let payload = #"{"choices":[{"index":0,"delta":{"role":"assistant"}}]}"#
+ XCTAssertNil(SseDeltas.openAiDelta(payload))
+ }
+
+ func testOpenAiDoneReturnsNil() {
+ XCTAssertNil(SseDeltas.openAiDelta("[DONE]"))
+ }
+
+ func testOpenAiEmptyChoicesReturnsNil() {
+ XCTAssertNil(SseDeltas.openAiDelta(#"{"choices":[]}"#))
+ }
+
+ func testOpenAiMalformedReturnsNil() {
+ XCTAssertNil(SseDeltas.openAiDelta("not json"))
+ }
+
+ func testOpenAiEmptyContentReturnsNil() {
+ // OpenAI's usual first delta carries `"content": ""`. Kotlin returns null (optString +
+ // takeIf { isNotEmpty() }); Swift must match so onDelta doesn't fire on one platform only
+ // (K14 haptics hang off delta arrival).
+ let payload = #"{"choices":[{"index":0,"delta":{"content":""}}]}"#
+ XCTAssertNil(SseDeltas.openAiDelta(payload))
+ }
+
+ // MARK: - Gemini delta
+
+ func testGeminiTextDelta() {
+ let payload = #"{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}"#
+ XCTAssertEqual(SseDeltas.geminiDelta(payload), "Hello")
+ }
+
+ func testGeminiMultiPartDelta() {
+ let payload = #"{"candidates":[{"content":{"parts":[{"text":"Hello "},{"text":"world"}]}}]}"#
+ XCTAssertEqual(SseDeltas.geminiDelta(payload), "Hello world")
+ }
+
+ func testGeminiFinishReasonChunkReturnsNil() {
+ let payload = #"{"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}]}"#
+ XCTAssertNil(SseDeltas.geminiDelta(payload))
+ }
+
+ func testGeminiMalformedReturnsNil() {
+ XCTAssertNil(SseDeltas.geminiDelta("not json"))
+ }
+
+ // MARK: - Anthropic delta
+
+ func testAnthropicTextDelta() {
+ let payload = #"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#
+ XCTAssertEqual(SseDeltas.anthropicDelta(payload), "Hello")
+ }
+
+ func testAnthropicMessageStartReturnsNil() {
+ let payload = #"{"type":"message_start","message":{"id":"msg_1"}}"#
+ XCTAssertNil(SseDeltas.anthropicDelta(payload))
+ }
+
+ func testAnthropicPingReturnsNil() {
+ let payload = #"{"type":"ping"}"#
+ XCTAssertNil(SseDeltas.anthropicDelta(payload))
+ }
+
+ func testAnthropicContentBlockStartReturnsNil() {
+ let payload = #"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#
+ XCTAssertNil(SseDeltas.anthropicDelta(payload))
+ }
+
+ func testAnthropicMessageStopReturnsNil() {
+ let payload = #"{"type":"message_stop"}"#
+ XCTAssertNil(SseDeltas.anthropicDelta(payload))
+ }
+
+ func testAnthropicMalformedReturnsNil() {
+ XCTAssertNil(SseDeltas.anthropicDelta("not json"))
+ }
+
+ func testAnthropicEmptyTextReturnsNil() {
+ // Parity with Kotlin: empty text → null, not "" (same reason as OpenAI empty content).
+ let payload = #"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}"#
+ XCTAssertNil(SseDeltas.anthropicDelta(payload))
+ }
+
+ // MARK: - Full-stream reassembly (the parity pin: streamed == non-streamed)
+
+ func testOpenAiReassembleMatchesFullReply() {
+ // A realistic 3-chunk OpenAI stream for the reply "Hello, world!".
+ let lines = [
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}",
+ "",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}",
+ "",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\", world!\"}}]}",
+ "",
+ "data: {\"choices\":[{\"index\":0,\"finish_reason\":\"stop\"}]}",
+ "",
+ "data: [DONE]",
+ "",
+ ]
+ let reassembled = SseDeltas.reassemble(lines: lines, provider: .openAi)
+ // The non-streamed reply would be "Hello, world!" (the full content from choices[0].message.content).
+ XCTAssertEqual(reassembled, "Hello, world!")
+ }
+
+ func testGeminiReassembleMatchesFullReply() {
+ let lines = [
+ "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Recovery\"}]}}]}",
+ "",
+ "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\" is \"}]}}]}",
+ "",
+ "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"good.\"}]},\"finishReason\":\"STOP\"}]}",
+ "",
+ ]
+ XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .gemini), "Recovery is good.")
+ }
+
+ func testAnthropicReassembleMatchesFullReply() {
+ let lines = [
+ "event: message_start",
+ "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}",
+ "",
+ "event: content_block_start",
+ "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}",
+ "",
+ "event: content_block_delta",
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Sleep\"}}",
+ "",
+ "event: content_block_delta",
+ "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" well.\"}}",
+ "",
+ "event: content_block_stop",
+ "data: {\"type\":\"content_block_stop\",\"index\":0}",
+ "",
+ "event: message_stop",
+ "data: {\"type\":\"message_stop\"}",
+ "",
+ ]
+ XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .anthropic), "Sleep well.")
+ }
+
+ func testCustomSharesOpenAiShape() {
+ let lines = [
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"}}]}",
+ "",
+ "data: [DONE]",
+ "",
+ ]
+ XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .custom), "Hi")
+ }
+
+ // MARK: - Empty / edge cases
+
+ func testEmptyStreamReassemblesToEmpty() {
+ XCTAssertEqual(SseDeltas.reassemble(lines: [], provider: .openAi), "")
+ }
+
+ func testOnlyDoneReassemblesToEmpty() {
+ XCTAssertEqual(SseDeltas.reassemble(lines: ["data: [DONE]", ""], provider: .openAi), "")
+ }
+
+ func testIgnoresCommentLines() {
+ let lines = [
+ ": keep-alive",
+ "",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}",
+ "",
+ "data: [DONE]",
+ "",
+ ]
+ XCTAssertEqual(SseDeltas.reassemble(lines: lines, provider: .openAi), "ok")
+ }
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift
index 4c838ad56c..4b4c561c59 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsCounterTests.swift
@@ -8,7 +8,9 @@ import WhoopProtocol
/// the caller's `stepTicksPerStep` calibration). Mirrors the Android StepsCounterTest vectors value-for-value.
final class StepsCounterTests: XCTestCase {
- private func step(_ ts: Int, _ counter: Int) -> StepSample { StepSample(ts: ts, counter: counter) }
+ private func step(_ ts: Int, _ counter: Int, _ activityClass: Int? = nil) -> StepSample {
+ StepSample(ts: ts, counter: counter, activityClass: activityClass)
+ }
func testSumsPositiveConsecutiveDeltas() {
// counters 100 -> 150 -> 220 => deltas 50 + 70 = 120
@@ -44,7 +46,32 @@ final class StepsCounterTests: XCTestCase {
func testMaxStepDeltaBoundaryIsExclusive() {
// Exactly maxStepDelta (512) is dropped; 511 counts.
- XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(60, 512)]), nil) // 512 dropped => no movement
- XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(60, 511)]), 511) // 511 kept
+ XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(128, 512)]), nil) // absolute guard
+ XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 0), step(128, 511)]), 511)
+ }
+
+ func testRejectsPhysicallyImpossibleOneSecondSpikeButAllowsSameTicksAcrossTime() {
+ XCTAssertNil(StepsCounter.stepsInWindow([step(0, 100), step(1, 107)]))
+ XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 100), step(2, 107)]), 7)
+ XCTAssertEqual(StepsCounter.stepsInWindow([step(0, 100), step(1, 104)]), 4)
+ }
+
+ func testClassedStreamCountsOnlyWalkAndRunDeltas() {
+ // Attribute each counter delta to the later sample, matching the strap's per-record class.
+ // still: +10 ignored; walk: +20; run: +15; unknown: +25 ignored => 35 locomotion ticks.
+ XCTAssertEqual(StepsCounter.stepsInWindow([
+ step(0, 100, 0),
+ step(10, 110, 0),
+ step(20, 130, 1),
+ step(30, 145, 2),
+ step(40, 170, nil),
+ ]), 35)
+ }
+
+ func testLegacyUnclassedStreamKeepsCounterFallback() {
+ // Rows written before activityClass existed must retain their historical estimate.
+ XCTAssertEqual(StepsCounter.stepsInWindow([
+ step(0, 100), step(10, 140), step(20, 170),
+ ]), 70)
}
}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift
index ff331aceda..21faf02666 100644
--- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDailyTests.swift
@@ -58,7 +58,7 @@ final class StepsDailyTests: XCTestCase {
func testJumpGuardDropsGapButKeepsRealSteps() {
// 100 -> 300 (=200 real) ; 300 -> 1200 is a 900-tick GAP (>= 512) and is dropped ; 1200 -> 1500
// (=300 real). Only the two in-range increments count => 200 + 300 = 500, the gap doesn't inflate.
- let s = [step(0, 100), step(60, 300), step(3_600, 1_200), step(3_660, 1_500)]
+ let s = [step(0, 100), step(60, 300), step(3_600, 1_200), step(3_675, 1_500)]
XCTAssertEqual(stepsFor(s), 500)
}
@@ -66,7 +66,7 @@ final class StepsDailyTests: XCTestCase {
// THE BUG (#132/#276/#316). A realistic ascending cumulative counter sampled at 1 Hz. The OLD
// code summed the raw running total (here, byte @57 alone summed) — exploding the count; the NEW
// wrap-aware diff sums only the per-record increments and yields a sane number.
- let counters = [100, 127, 127, 130, 131, 131, 140, 152, 160, 175]
+ let counters = [100, 102, 102, 105, 106, 106, 109, 111, 113, 115]
let samples = counters.enumerated().map { step($0.offset, $0.element) }
// NEW behaviour: sum of wrap-aware deltas == last - first (all small increments, none >= 512).
let sane = counters.last! - counters.first! // 75
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StreamReadCapTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StreamReadCapTests.swift
new file mode 100644
index 0000000000..26baf206bf
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StreamReadCapTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import XCTest
+@testable import StrandAnalytics
+
+/// Per-stream read caps (#1538) — twin of the Kotlin `StreamReadCapTest`, same cases and same numbers.
+final class StreamReadCapTests: XCTestCase {
+
+ /// THE invariant. A cap must exceed what a full window can legitimately hold, or a complete read is
+ /// indistinguishable from a truncated one — and the truncated one silently loses its newest rows.
+ func testCapExceedsAFullWindowForEveryStream() {
+ let fullHR = Double(StreamReadCap.windowSeconds) * StreamReadCap.hrRowsPerSecond
+ let fullRR = Double(StreamReadCap.windowSeconds) * StreamReadCap.rrRowsPerSecond
+ XCTAssertGreaterThan(Double(StreamReadCap.hr), fullHR)
+ XCTAssertGreaterThan(Double(StreamReadCap.rr), fullRR)
+ }
+
+ /// The test above is derived from the same constants as the caps, so on its own it only asserts that
+ /// headroom exceeds 1. This one is anchored OUTSIDE the type, to a number the field produced: R-R
+ /// came back at 200,000 ten times in one capture, so that value is known-insufficient rather than
+ /// theorised. A cap at or below it would reintroduce the bug no matter how the arithmetic reads.
+ func testCapsClearTheValueThatTruncatedInTheField() {
+ let knownInsufficient = 200_000
+ XCTAssertGreaterThan(StreamReadCap.rr, knownInsufficient)
+ XCTAssertGreaterThan(StreamReadCap.hr, knownInsufficient,
+ "HR sat 3% under this and was lucky, not safe")
+ }
+
+ /// The window is not restated here — the engine reads `dayStart - lookbackSeconds`, so these are the
+ /// same number by construction. Pinned so that splitting them again is a visible change.
+ func testWindowIsItsTwoHalves() {
+ XCTAssertEqual(StreamReadCap.windowSeconds,
+ StreamReadCap.lookbackSeconds + StreamReadCap.forwardSeconds)
+ XCTAssertEqual(StreamReadCap.lookbackSeconds, 30 * 3_600)
+ XCTAssertEqual(StreamReadCap.forwardSeconds, 86_400)
+ }
+
+ /// The regression itself, in the numbers that caused it. The old shared cap of 200,000 was ABOVE a
+ /// full HR window and BELOW a full R-R one — which is exactly why HR never truncated, R-R always did,
+ /// and one number looked adequate from the HR side.
+ func testTheOldSharedCapWasBelowAFullRRWindow() {
+ let oldSharedCap = 200_000.0
+ let fullHR = Double(StreamReadCap.windowSeconds) * StreamReadCap.hrRowsPerSecond
+ let fullRR = Double(StreamReadCap.windowSeconds) * StreamReadCap.rrRowsPerSecond
+ XCTAssertGreaterThan(oldSharedCap, fullHR, "the old cap fitted HR, which is why it looked fine")
+ XCTAssertLessThan(oldSharedCap, fullRR, "and did not fit R-R, which is why nights were clipped")
+ XCTAssertGreaterThan(Double(StreamReadCap.rr), fullRR, "the new cap does fit it")
+ }
+
+ /// R-R must be capped higher than HR: it is one row per BEAT, not one per second.
+ func testRRIsCappedHigherThanHR() {
+ XCTAssertGreaterThan(StreamReadCap.rr, StreamReadCap.hr)
+ }
+
+ /// The window is 54 hours — `dayStart - 30h` running through the night. Pinned because both caps are
+ /// derived from it, so a silent change here would resize them both.
+ func testWindowIsFiftyFourHours() {
+ XCTAssertEqual(StreamReadCap.windowSeconds, 54 * 3_600)
+ XCTAssertEqual(StreamReadCap.hr, 291_600)
+ XCTAssertEqual(StreamReadCap.rr, 583_200)
+ XCTAssertEqual(StreamReadCap.gravity, 291_600)
+ }
+
+ /// Gravity is the third stream on the 54-hour window, and the field capture put it at 192,698 rows -
+ /// 96% of the old shared cap. Unlike HR and R-R it is a PLAIN read with no truncation counter, so a
+ /// clip there reports nothing at all; sleep staging simply gets a night missing its tail.
+ func testGravityClearsWhatTheFieldMeasured() {
+ let measuredInField = 192_698
+ XCTAssertGreaterThan(StreamReadCap.gravity, measuredInField)
+ XCTAssertGreaterThan(StreamReadCap.gravity, 200_000, "the old shared cap it sat 96% of")
+ }
+
+}
diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift
new file mode 100644
index 0000000000..366218942e
--- /dev/null
+++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift
@@ -0,0 +1,89 @@
+import XCTest
+@testable import StrandAnalytics
+
+/// Pins the windowed-read planner. Twin of Kotlin `WindowedStreamPlanTest` — same table, same answers.
+///
+/// Two properties matter here and nothing else does. The walk must collapse to ONE stride of reading per
+/// day, which is the whole point; and every case the planner cannot prove safe must land on `.fullRead`,
+/// because this sits on the scoring path where an optimisation that guesses produces a wrong number rather
+/// than a slow one.
+final class WindowedStreamPlanTests: XCTestCase {
+
+ private let day = 86_400
+ private let h30 = 108_000
+ private let midnight = 1_787_875_200
+
+ /// The real shape: days walked backwards, 54-hour windows on a 24-hour stride. Day 0 reads in full
+ /// because nothing is buffered; every day after it extends by EXACTLY one stride, which is the 2.25x
+ /// overlap being removed.
+ func testBackwardWalkExtendsByExactlyOneStridePerDay() {
+ var owner: String?
+ var cf = 0, ct = 0
+ var plans: [WindowedStreamPlan.Plan] = []
+ for offset in 0..<5 {
+ let dayStart = midnight - offset * day
+ let from = dayStart - h30, to = dayStart + day
+ plans.append(WindowedStreamPlan.plan(cachedOwner: owner, cachedFrom: cf, cachedTo: ct,
+ cachedTruncated: false, owner: "w", from: from, to: to))
+ owner = "w"; cf = from; ct = to
+ }
+ XCTAssertEqual(plans[0], .fullRead)
+ XCTAssertEqual(plans[1], .extend(readFrom: 1_787_680_800, readTo: 1_787_767_199))
+ XCTAssertEqual(plans[2], .extend(readFrom: 1_787_594_400, readTo: 1_787_680_799))
+ XCTAssertEqual(plans[3], .extend(readFrom: 1_787_508_000, readTo: 1_787_594_399))
+ XCTAssertEqual(plans[4], .extend(readFrom: 1_787_421_600, readTo: 1_787_507_999))
+ // Each extension is one stride, inclusive at both ends — the store's range predicate is too.
+ for p in plans.dropFirst() {
+ guard case let .extend(readFrom, readTo) = p else { return XCTFail("expected .extend, got \(p)") }
+ XCTAssertEqual(readTo - readFrom + 1, day)
+ }
+ }
+
+ /// Everything the planner cannot prove safe reads in full — the pre-existing behaviour, unchanged.
+ func testUnprovableCasesReadInFull() {
+ func p(_ co: String?, _ cf: Int, _ ct: Int, _ trunc: Bool, _ o: String, _ f: Int, _ t: Int)
+ -> WindowedStreamPlan.Plan {
+ WindowedStreamPlan.plan(cachedOwner: co, cachedFrom: cf, cachedTo: ct,
+ cachedTruncated: trunc, owner: o, from: f, to: t)
+ }
+ XCTAssertEqual(p("a", 100, 200, false, "b", 50, 150), .fullRead)
+ XCTAssertEqual(p("w", 100, 200, true, "w", 50, 150), .fullRead)
+ XCTAssertEqual(p(nil, 0, 0, false, "w", 50, 150), .fullRead)
+ XCTAssertEqual(p("w", 100, 200, false, "w", 10, 99), .fullRead)
+ XCTAssertEqual(p("w", 100, 200, false, "w", 150, 250), .fullRead)
+ XCTAssertEqual(p("w", 200, 100, false, "w", 50, 150), .fullRead)
+ XCTAssertEqual(p("w", 100, 200, false, "w", 150, 50), .fullRead)
+ }
+
+ /// A day whose window the buffer already covers reads nothing. The boundary case is the one worth
+ /// pinning: `to` landing exactly ON the buffer's start still extends only up to the row below it, so the
+ /// boundary row is never read twice.
+ func testCoveredServesAndBoundaryNeverDoubleReads() {
+ func p(_ cf: Int, _ ct: Int, _ f: Int, _ t: Int) -> WindowedStreamPlan.Plan {
+ WindowedStreamPlan.plan(cachedOwner: "w", cachedFrom: cf, cachedTo: ct,
+ cachedTruncated: false, owner: "w", from: f, to: t)
+ }
+ XCTAssertEqual(p(100, 200, 120, 180), .serve)
+ XCTAssertEqual(p(100, 200, 100, 200), .serve)
+ XCTAssertEqual(p(100, 200, 50, 150), .extend(readFrom: 50, readTo: 99))
+ XCTAssertEqual(p(100, 200, 50, 100), .extend(readFrom: 50, readTo: 99))
+ }
+
+ /// The saved-rows line. Twin of Kotlin `logLineShape` — same inputs, same string, and the shape is
+ /// pinned because a diagnostic nobody asserts is a diagnostic that can drift into meaning nothing.
+ ///
+ /// The zero case is the one worth having: a pass where the windows decline entirely must SAY so rather
+ /// than simply omit the line, so a reader can tell "the buffer saved nothing" from "the buffer was
+ /// never asked".
+ func testLogLine() {
+ XCTAssertEqual(
+ WindowedStreamPlan.logLine(hrRead: 1_000, hrServed: 9_000, hrTruncated: 0,
+ rrRead: 250, rrServed: 750, rrTruncated: 3),
+ "analyzeRecent windows hr[read=1000 served=9000 truncated=0] "
+ + "rr[read=250 served=750 truncated=3]")
+ XCTAssertEqual(
+ WindowedStreamPlan.logLine(hrRead: 0, hrServed: 0, hrTruncated: 0,
+ rrRead: 0, rrServed: 0, rrTruncated: 0),
+ "analyzeRecent windows hr[read=0 served=0 truncated=0] rr[read=0 served=0 truncated=0]")
+ }
+}
diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift
index 0e258d579b..c0a2adc2de 100644
--- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift
+++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift
@@ -276,6 +276,16 @@ public enum StrandPalette {
public static var restDeep: Color { isClassic ? Color(light: "#203E73", dark: "#2A4C8F") : Color(light: "#234F9E", dark: "#2F6FCB") }
public static var restBright: Color { isClassic ? Color(light: "#6A4FC0", dark: "#8E6FD6") : Color(light: "#5790DA", dark: "#6FA8E8") }
public static var restGlow: Color { isClassic ? Color(light: "#3A80D6", dark: "#6FA8E8") : Color(light: "#3A80D6", dark: "#4A90E2") }
+ /// The Rest family's most legible LINE colour — for strokes that must read on a busy or translucent
+ /// surface, such as the body-clock dial's arcs over a custom background image.
+ ///
+ /// Introduces no new value: it selects the existing token that is the bright blue in each palette.
+ /// The families are not parallel — classic's `restBright` is a PURPLE accent while modern's is the
+ /// blue, and classic's `restColor` is the blue where modern's is a muted steel — so a card naming
+ /// either token directly gets the right colour in one palette and the wrong one in the other. Both
+ /// resolve to #6FA8E8 in dark.
+ public static var restLine: Color { isClassic ? restColor : restBright }
+
public static var restGradient: Gradient { Gradient(colors: [restDeep, restBright]) }
/// Stress — blue→gold→orange world / Classic green→amber→red.
diff --git a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings
index df894b30cd..5af055cf65 100644
--- a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings
+++ b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings
@@ -2308,6 +2308,12 @@
"state": "translated",
"value": "51 bpm"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "51 уд/мин"
+ }
}
}
},
@@ -2342,6 +2348,12 @@
"state": "translated",
"value": "7h 42m śpi · 92% ewydajność"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "7 ч 42 мин сна · эффективность 92%"
+ }
}
}
},
@@ -2376,6 +2388,12 @@
"state": "translated",
"value": "87% · streaming"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "87% · передача"
+ }
}
}
},
@@ -2399,6 +2417,12 @@
"value": "Ajouter"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Добавить"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -2450,6 +2474,12 @@
"state": "translated",
"value": "Przyjęte przez NoopButtonStyle"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Применяется через NoopButtonStyle"
+ }
}
}
},
@@ -2484,6 +2514,12 @@
"state": "translated",
"value": "Tylko bar"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Только полоса"
+ }
}
}
},
@@ -2518,6 +2554,12 @@
"state": "translated",
"value": "Body / Regular 15 — wątek o Tobie, przeczytaj w całości."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Body / Regular 15 — нить о вас, прочитанная целиком."
+ }
}
}
},
@@ -2552,6 +2594,12 @@
"state": "translated",
"value": "PODLICZANIE WYNIKÓW"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ОЦЕНКА С ОТСЧЁТОМ"
+ }
}
}
},
@@ -2586,6 +2634,12 @@
"state": "translated",
"value": "Spokojna noc"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Спокойная ночь"
+ }
}
}
},
@@ -2654,6 +2708,12 @@
"state": "translated",
"value": "Karta %lld"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Карточка %lld"
+ }
}
}
},
@@ -2677,6 +2737,12 @@
"value": "Terminé"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Готово"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -2762,6 +2828,12 @@
"state": "translated",
"value": "HRV (ms) — 30 dni"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ВСР (мс) — 30 дней"
+ }
}
}
},
@@ -2830,6 +2902,12 @@
"state": "translated",
"value": "Tętno, 24 godziny"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пульс, 24 часа"
+ }
}
}
},
@@ -2864,6 +2942,12 @@
"state": "translated",
"value": "Najedź kursorem na zespół: nazwa sceny, początek i koniec zegara oraz czas trwania."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите на полосу: название стадии, время начала и конца и длительность."
+ }
}
}
},
@@ -2898,6 +2982,12 @@
"state": "translated",
"value": "Najedź kursorem na komórkę: pierścień + data, wynik i podpowiedź dotycząca stanu odzyskiwania."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите на ячейку: кольцо и дата, оценка и подсказка о состоянии восстановления."
+ }
}
}
},
@@ -2932,6 +3022,12 @@
"state": "translated",
"value": "Najedź kursorem na wskaźnik obciążenia i podpowiedzi dotyczącego słowa ładowania."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите на индикатор, чтобы увидеть подсказку с нагрузкой и её словесной оценкой."
+ }
}
}
},
@@ -2966,6 +3062,12 @@
"state": "translated",
"value": "Najedź kursorem na pierścień, aby odzyskać + podpowiedź ze słowem stanu."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите на кольцо, чтобы увидеть подсказку с восстановлением и его словесной оценкой."
+ }
}
}
},
@@ -3000,6 +3102,12 @@
"state": "translated",
"value": "Najedź kursorem na dowolną linię przebiegu, aby odczytać dokładną próbkę pod kursorem."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите на любой мини-график, чтобы прочитать точное значение под курсором."
+ }
}
}
},
@@ -3034,6 +3142,12 @@
"state": "translated",
"value": "Najedź kursorem na karty, aby zobaczyć windę."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите на карточки, чтобы увидеть подъём."
+ }
}
}
},
@@ -3068,6 +3182,12 @@
"state": "translated",
"value": "Najedź kursorem na linię: celownik + kropka + podpowiedź z datą/wartością."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите на линию: перекрестие, точка и подсказка с датой и значением."
+ }
}
}
},
@@ -3102,6 +3222,12 @@
"state": "translated",
"value": "Najedź kursorem, aby przeczytać HRV każdego dnia w ms."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Наведите, чтобы прочитать ВСР каждого дня в мс."
+ }
}
}
},
@@ -3136,6 +3262,12 @@
"state": "translated",
"value": "W karcie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "В карточке"
+ }
}
}
},
@@ -3170,6 +3302,12 @@
"state": "translated",
"value": "Ubiegłej nocy"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Последняя ночь"
+ }
}
}
},
@@ -3204,6 +3342,12 @@
"state": "translated",
"value": "Ruch podczas snu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Движение во сне"
+ }
}
}
},
@@ -3272,6 +3416,12 @@
"state": "translated",
"value": "Przegląd HR"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пульс, обзор"
+ }
}
}
},
@@ -3306,6 +3456,12 @@
"state": "translated",
"value": "GRADIDENT ODZYSKU"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ГРАДИЕНТ ВОССТАНОВЛЕНИЯ"
+ }
}
}
},
@@ -3340,6 +3496,12 @@
"state": "translated",
"value": "Regeneracja — 30 dni"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Восстановление — 30 дней"
+ }
}
}
},
@@ -3374,6 +3536,12 @@
"state": "translated",
"value": "Regeneracja — ubiegły rok"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Восстановление — за прошлый год"
+ }
}
}
},
@@ -3408,6 +3576,12 @@
"state": "translated",
"value": "Zmniejsz ruch renderuje statycznie w ostatniej klatce."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "При включённом «Уменьшении движения» отображается статичный последний кадр."
+ }
}
}
},
@@ -3431,6 +3605,12 @@
"value": "Supprimer"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Удалить"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -3482,6 +3662,12 @@
"state": "translated",
"value": "Odtwórz ponownie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Повторить появление"
+ }
}
}
},
@@ -3505,6 +3691,12 @@
"value": "FC au repos"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пульс в покое"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -3556,6 +3748,12 @@
"state": "translated",
"value": "Niespokojna noc"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Беспокойная ночь"
+ }
}
}
},
@@ -3590,6 +3788,12 @@
"state": "translated",
"value": "Rzuć numerem"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Прокрутить число"
+ }
}
}
},
@@ -3624,6 +3828,12 @@
"state": "translated",
"value": "MIĘKKIE PRZEJŚCIE KARTY"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "МЯГКИЙ ПЕРЕХОД КАРТОЧКИ"
+ }
}
}
},
@@ -3658,6 +3868,12 @@
"state": "translated",
"value": "WYGLĄDAJĄ ZNACZNIE"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ПОСЛЕДОВАТЕЛЬНОЕ ПОЯВЛЕНИЕ"
+ }
}
}
},
@@ -3692,6 +3908,12 @@
"state": "translated",
"value": "RAMPA OBCIĄŻENIA"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "НАРАСТАНИЕ НАГРУЗКИ"
+ }
}
}
},
@@ -3715,6 +3937,12 @@
"value": "Enregistrer"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Сохранить"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -3766,6 +3994,12 @@
"state": "translated",
"value": "Liczba segmentów i wysokości"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Количество и высота сегментов"
+ }
}
}
},
@@ -3800,6 +4034,12 @@
"state": "translated",
"value": "Wydajność snu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Качество сна"
+ }
}
}
},
@@ -3834,6 +4074,12 @@
"state": "translated",
"value": "Etapy snu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Стадии сна"
+ }
}
}
},
@@ -3970,6 +4216,12 @@
"state": "translated",
"value": "Przełącz delikatną wiosnę"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Переключить мягкую пружину"
+ }
}
}
},
@@ -4004,6 +4256,12 @@
"state": "translated",
"value": "Wartości"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Значения"
+ }
}
}
},
@@ -4072,6 +4330,12 @@
"state": "translated",
"value": "godzina %lld"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "час %lld"
+ }
}
}
},
diff --git a/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift b/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift
index 5c23641340..00bf923510 100644
--- a/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift
+++ b/Packages/StrandImport/Sources/StrandImport/AppleHealthImporter.swift
@@ -1,4 +1,9 @@
import Foundation
+#if canImport(FoundationXML)
+// corelibs Foundation moved the event-driven XML parser into its own module; Darwin keeps it in
+// Foundation. Conditional so one import serves both, with no #if at the use sites.
+import FoundationXML
+#endif
import ZIPFoundation
/// Parses an Apple Health export (`export.xml`, possibly inside `export.zip`)
diff --git a/Packages/StrandImport/Sources/StrandImport/CorelibsCompat.swift b/Packages/StrandImport/Sources/StrandImport/CorelibsCompat.swift
new file mode 100644
index 0000000000..4867b5f83e
--- /dev/null
+++ b/Packages/StrandImport/Sources/StrandImport/CorelibsCompat.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+// Small corelibs shims so the package builds on Linux as well as Darwin. Each one exists because a
+// Foundation facility this package uses is Objective-C-runtime-backed on Apple platforms and simply
+// absent elsewhere — never because behaviour should differ.
+
+#if !canImport(ObjectiveC)
+/// No-op stand-in for Apple's `autoreleasepool`.
+///
+/// Without an Objective-C runtime there are no autoreleased temporaries to drain, so running the body
+/// directly is the exact equivalent rather than an approximation. The Darwin call sites keep their real
+/// pool, which is load-bearing there: the Apple Health importer drains per XML element because a
+/// multi-year `export.xml` bridges tens of millions of attribute dictionaries.
+///
+/// Mirrors the shim GRDB already ships for the same reason, so the two agree on the spelling.
+@inlinable func autoreleasepool(invoking body: () throws -> Result) rethrows -> Result {
+ try body()
+}
+#endif
diff --git a/Packages/StrandImport/Sources/StrandImport/GpxParser.swift b/Packages/StrandImport/Sources/StrandImport/GpxParser.swift
index 2623001ecf..29b1ab9806 100644
--- a/Packages/StrandImport/Sources/StrandImport/GpxParser.swift
+++ b/Packages/StrandImport/Sources/StrandImport/GpxParser.swift
@@ -1,4 +1,9 @@
import Foundation
+#if canImport(FoundationXML)
+// corelibs Foundation moved the event-driven XML parser into its own module; Darwin keeps it in
+// Foundation. Conditional so one import serves both, with no #if at the use sites.
+import FoundationXML
+#endif
// MARK: - GPX parser (the universal GPS-track format)
//
diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift
index 8538fe9198..9e3e773822 100644
--- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift
+++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift
@@ -154,4 +154,70 @@ public enum HealthWriteback {
}
return out
}
+
+ // MARK: - Apple Health external UUID keys (#1503)
+
+ /// The deterministic `HKMetadataKeyExternalUUID` for a NOOP-written Apple Health record.
+ ///
+ /// The key is a natural key — `noop::` — with NO device-id segment. The previous
+ /// scheme embedded the active strap id (`noop:::`), which is NOT durable:
+ /// a re-pair or serial-based identification changes it, and every record written under the old id
+ /// becomes unreachable to the delete-then-write reconciliation (the delete predicate is built from
+ /// the id that is active NOW). The stranded records stay in Apple Health permanently as duplicates.
+ ///
+ /// Dropping the id segment matches the Android twin (`HealthConnectWriter.clientRecordId` carries
+ /// no device id — `noop-workout-`) and makes the key mean what it is: a natural key over
+ /// the record's own identity, stable across strap lifecycle changes.
+ ///
+ /// `kind` is the metric's `HKQuantityTypeIdentifier.rawValue` for vitals, `"sleep"` for sleep
+ /// sessions, or `"workout"` for workouts. `identity` is the day key ("yyyy-MM-dd") for vitals or
+ /// the unix-second start timestamp for sleep/workout.
+ public static func appleHealthExternalUUID(kind: String, identity: String) -> String {
+ "noop:\(kind):\(identity)"
+ }
+
+ /// The vitals key: `noop::`.
+ public static func appleHealthVitalKey(metricId: String, day: String) -> String {
+ appleHealthExternalUUID(kind: metricId, identity: day)
+ }
+
+ /// The sleep key: `noop:sleep:`.
+ public static func appleHealthSleepKey(startTs: Int) -> String {
+ appleHealthExternalUUID(kind: "sleep", identity: "\(startTs)")
+ }
+
+ /// The workout key: `noop:workout:`.
+ public static func appleHealthWorkoutKey(startTs: Int) -> String {
+ appleHealthExternalUUID(kind: "workout", identity: "\(startTs)")
+ }
+
+ // MARK: - #1503 stranded-records sweep completion tracking
+ //
+ // The one-off sweep that clears Apple Health records written under the OLD device-id-keyed
+ // scheme must be PER-TYPE, not a single boolean. A single flag set unconditionally loses the
+ // migration permanently when authorization is partial (sleep granted, workouts declined) or
+ // when a `deleteObjects` call fails — both of which read as "already done" and leave the
+ // stranded records unreachable forever. Per-type tracking lets an install that could not
+ // complete the sweep finish it later, once the missing authorization arrives or the failing
+ // delete succeeds on a retry.
+ //
+ // These pure helpers model the decision so it is unit-testable without HealthKit (HealthKit
+ // itself can't run under `swift test`). The app-target migration (`HealthKitBridge`) loads the
+ // already-swept set from UserDefaults, computes the types to attempt, attempts each delete, and
+ // records only the types whose delete SUCCEEDED back into the swept set.
+
+ /// The type ids that should be attempted this run: authorized for sharing but not yet swept.
+ /// A type that was declined (absent from `authorized`) is never attempted and never marked
+ /// swept, so a later grant still gets swept. A type whose delete failed (not in `swept`) is
+ /// retried on the next write-back run.
+ public static func strandedSweepPending(swept: Set, authorized: Set) -> Set {
+ authorized.subtracting(swept)
+ }
+
+ /// The new swept set after this run: the union of previously swept types and the types whose
+ /// delete SUCCEEDED this run. A type whose delete threw (absent from `succeededThisRun`) is NOT
+ /// marked swept, so it is retried next run rather than silently abandoned.
+ public static func strandedSweepResult(swept: Set, succeededThisRun: Set) -> Set {
+ swept.union(succeededThisRun)
+ }
}
diff --git a/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift b/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift
index 0e0ade92d9..8563821d9f 100644
--- a/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift
+++ b/Packages/StrandImport/Sources/StrandImport/RouteExporter.swift
@@ -39,8 +39,7 @@ public enum RouteExporter {
) -> Data {
switch format {
case .gpx:
- return Data(buildGpx(route: route, startTs: startTs, endTs: endTs, sport: sport,
- distanceM: distanceM).utf8)
+ return Data(buildGpx(route: route, startTs: startTs, endTs: endTs, sport: sport).utf8)
case .fit:
return buildFit(route: route, startTs: startTs, endTs: endTs, sport: sport,
distanceM: distanceM, energyKcal: energyKcal, avgHr: avgHr, maxHr: maxHr)
@@ -48,12 +47,13 @@ public enum RouteExporter {
}
/// GPX 1.1 track. Each `trkpt` carries lat/lon + an interpolated `time`; no `ele`/HR (none stored).
+ /// Takes no distance: GPX 1.1 has no track-level distance element, so a reader derives it from the
+ /// trackpoints. FIT does carry one, which is why ``buildFit`` still takes it. Kotlin twin matches.
public static func buildGpx(
route: [RoutePoint],
startTs: Int,
endTs: Int,
- sport: String?,
- distanceM: Double? = nil
+ sport: String?
) -> String {
let canon = canonicalSport(sport)
let times = interpolatedTimes(route.count, startTs, endTs)
diff --git a/Packages/StrandImport/Sources/StrandImport/TcxParser.swift b/Packages/StrandImport/Sources/StrandImport/TcxParser.swift
index 6a393c492f..40d9040afa 100644
--- a/Packages/StrandImport/Sources/StrandImport/TcxParser.swift
+++ b/Packages/StrandImport/Sources/StrandImport/TcxParser.swift
@@ -1,4 +1,9 @@
import Foundation
+#if canImport(FoundationXML)
+// corelibs Foundation moved the event-driven XML parser into its own module; Darwin keeps it in
+// Foundation. Conditional so one import serves both, with no #if at the use sites.
+import FoundationXML
+#endif
// MARK: - TCX parser (Garmin Training Center XML)
//
diff --git a/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift b/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift
index c59d2767e1..b147014014 100644
--- a/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift
+++ b/Packages/StrandImport/Sources/StrandImport/WhoopExportImporter.swift
@@ -268,7 +268,16 @@ public struct WhoopExportImporter {
r.recoveryScore = row.double("recovery_score_pct")
r.restingHeartRate = row.double("resting_heart_rate_bpm", "resting_heart_rate")
r.hrvMs = row.double("heart_rate_variability_ms", "heart_rate_variability_rmssd_ms")
- r.skinTempCelsius = row.double("skin_temp_celsius", "skin_temp_f")
+ // #1849: a Fahrenheit WHOOP export ships `skin_temp_f`, NOT `skin_temp_celsius`. Treating
+ // the two as aliases stored the °F value unconverted (92.3 °F → 92.3 in a °C column),
+ // which read as a lethal fever and poisoned the baseline. Read each as its OWN key and
+ // convert the Fahrenheit value on the way in. `skin_temp_celsius` wins when both are
+ // present (a Celsius export is the canonical form).
+ if let c = row.double("skin_temp_celsius") {
+ r.skinTempCelsius = c
+ } else if let f = row.double("skin_temp_f") {
+ r.skinTempCelsius = (f - 32.0) * 5.0 / 9.0
+ }
r.bloodOxygenPct = row.double("blood_oxygen_pct", "blood_oxygen_pct_pct")
r.dayStrain = row.double("day_strain")
r.energyKcal = row.double("energy_burned_cal") // CSV "(cal)" == kcal
diff --git a/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift
index c420275835..13d50e5452 100644
--- a/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift
+++ b/Packages/StrandImport/Tests/StrandImportTests/AppleHealthImporterTests.swift
@@ -1,4 +1,8 @@
import XCTest
+#if canImport(FoundationXML)
+// See the source files: corelibs keeps XMLParser in its own module.
+import FoundationXML
+#endif
@testable import StrandImport
final class AppleHealthImporterTests: XCTestCase {
diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift
index 40f08c789b..49bffa8885 100644
--- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift
+++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift
@@ -183,4 +183,139 @@ final class HealthWritebackTests: XCTestCase {
XCTAssertEqual(plan[0].allKeyStartTs, [t, t + 600])
XCTAssertEqual(plan[0].intervals, [.init(start: t + 600, end: t + 7_200, kind: .unspecified)])
}
+
+ // MARK: - Apple Health external UUID keys (#1503)
+
+ /// The key must NOT embed a device id — the active strap id is not durable and embedding it
+ /// stranded every prior record in Apple Health as unreachable duplicates after a re-pair.
+ func testVitalKeyHasNoDeviceIdSegment() {
+ let key = HealthWriteback.appleHealthVitalKey(metricId: "restingHeartRate", day: "2026-08-27")
+ XCTAssertEqual(key, "noop:restingHeartRate:2026-08-27")
+ // The key must be STABLE — no strap id, no hash, nothing that changes on a re-pair.
+ XCTAssertFalse(key.contains("my-whoop"),
+ "the vital key must not embed a device id (#1503)")
+ }
+
+ func testSleepKeyHasNoDeviceIdSegment() {
+ let startTs = 1_700_000_000
+ let key = HealthWriteback.appleHealthSleepKey(startTs: startTs)
+ XCTAssertEqual(key, "noop:sleep:1700000000")
+ XCTAssertFalse(key.contains("my-whoop"),
+ "the sleep key must not embed a device id (#1503)")
+ }
+
+ func testWorkoutKeyHasNoDeviceIdSegment() {
+ let startTs = 1_700_000_000
+ let key = HealthWriteback.appleHealthWorkoutKey(startTs: startTs)
+ XCTAssertEqual(key, "noop:workout:1700000000")
+ XCTAssertFalse(key.contains("my-whoop"),
+ "the workout key must not embed a device id (#1503)")
+ }
+
+ /// The key is deterministic: the same inputs always produce the same key, so the
+ /// delete-then-write reconciliation can find prior records across app restarts and strap
+ /// lifecycle changes.
+ func testKeysAreDeterministic() {
+ XCTAssertEqual(
+ HealthWriteback.appleHealthVitalKey(metricId: "heartRateVariabilitySDNN", day: "2026-01-01"),
+ HealthWriteback.appleHealthVitalKey(metricId: "heartRateVariabilitySDNN", day: "2026-01-01"))
+ XCTAssertEqual(
+ HealthWriteback.appleHealthSleepKey(startTs: 123),
+ HealthWriteback.appleHealthSleepKey(startTs: 123))
+ XCTAssertEqual(
+ HealthWriteback.appleHealthWorkoutKey(startTs: 456),
+ HealthWriteback.appleHealthWorkoutKey(startTs: 456))
+ }
+
+ /// Different metrics on the same day produce different keys (no collision).
+ func testVitalKeysDifferByMetric() {
+ let day = "2026-08-27"
+ let rhr = HealthWriteback.appleHealthVitalKey(metricId: "restingHeartRate", day: day)
+ let hrv = HealthWriteback.appleHealthVitalKey(metricId: "heartRateVariabilitySDNN", day: day)
+ let spo2 = HealthWriteback.appleHealthVitalKey(metricId: "oxygenSaturation", day: day)
+ let resp = HealthWriteback.appleHealthVitalKey(metricId: "respiratoryRate", day: day)
+ XCTAssertNotEqual(rhr, hrv)
+ XCTAssertNotEqual(rhr, spo2)
+ XCTAssertNotEqual(rhr, resp)
+ XCTAssertNotEqual(hrv, spo2)
+ XCTAssertNotEqual(hrv, resp)
+ XCTAssertNotEqual(spo2, resp)
+ }
+
+ // MARK: - #1503 stranded-records sweep completion tracking (per-type, not a single boolean)
+
+ /// An authorized type that has not been swept is pending — it should be attempted this run.
+ func testStrandedSweepPendingForAuthorizedUnsweptType() {
+ let pending = HealthWriteback.strandedSweepPending(
+ swept: [], authorized: ["restingHeartRate", "sleepAnalysis"])
+ XCTAssertEqual(pending, ["restingHeartRate", "sleepAnalysis"])
+ }
+
+ /// A type that was already swept is NOT pending again — the sweep is once per install per type.
+ func testStrandedSweepSkipsAlreadySweptType() {
+ let pending = HealthWriteback.strandedSweepPending(
+ swept: ["restingHeartRate"], authorized: ["restingHeartRate", "sleepAnalysis"])
+ XCTAssertEqual(pending, ["sleepAnalysis"])
+ }
+
+ /// A type the user DECLINED (absent from authorized) is never pending — it is not attempted and
+ /// not marked swept, so a later grant still gets swept. This is the partial-authorization case.
+ func testStrandedSweepNeverAttemptsDeclinedType() {
+ let pending = HealthWriteback.strandedSweepPending(
+ swept: [], authorized: ["restingHeartRate"]) // workouts declined
+ XCTAssertFalse(pending.contains("workout"))
+ XCTAssertEqual(pending, ["restingHeartRate"])
+ }
+
+ /// A type whose delete SUCCEEDED this run is added to the swept set, so it is not retried.
+ func testStrandedSweepResultRecordsSuccessfulDelete() {
+ let updated = HealthWriteback.strandedSweepResult(
+ swept: ["restingHeartRate"], succeededThisRun: ["sleepAnalysis"])
+ XCTAssertEqual(updated, ["restingHeartRate", "sleepAnalysis"])
+ }
+
+ /// A type whose delete FAILED (absent from succeededThisRun) is NOT added to the swept set, so
+ /// it is retried on the next write-back run instead of being silently abandoned.
+ func testStrandedSweepResultDoesNotRecordFailedDelete() {
+ let updated = HealthWriteback.strandedSweepResult(
+ swept: ["restingHeartRate"], succeededThisRun: []) // sleep delete threw
+ XCTAssertEqual(updated, ["restingHeartRate"])
+ XCTAssertFalse(updated.contains("sleepAnalysis"))
+ }
+
+ /// The partial-authorization recovery sequence: run 1 sweeps only the authorized type; after a
+ /// later grant, run 2 sweeps the newly-authorized type. The migration completes across runs.
+ func testStrandedSweepCompletesAcrossRunsAfterLateAuthorizationGrant() {
+ // Run 1: sleep granted, workouts declined. Only sleep is authorized.
+ var swept: Set = []
+ let authRun1: Set = ["sleepAnalysis"]
+ let pending1 = HealthWriteback.strandedSweepPending(swept: swept, authorized: authRun1)
+ XCTAssertEqual(pending1, ["sleepAnalysis"])
+ // Sleep delete succeeds; workouts were never attempted (declined).
+ swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["sleepAnalysis"])
+ XCTAssertEqual(swept, ["sleepAnalysis"])
+ // Run 2: user later grants workouts.
+ let authRun2: Set = ["sleepAnalysis", "workout"]
+ let pending2 = HealthWriteback.strandedSweepPending(swept: swept, authorized: authRun2)
+ XCTAssertEqual(pending2, ["workout"], "the late-granted workout type must still be swept")
+ // Workout delete succeeds; the migration is now complete.
+ swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["workout"])
+ XCTAssertEqual(swept, ["sleepAnalysis", "workout"])
+ }
+
+ /// A delete that fails on run 1 is retried on run 2 and succeeds — the migration is not lost.
+ func testStrandedSweepRetriesFailedDeleteOnNextRun() {
+ var swept: Set = []
+ let auth: Set = ["restingHeartRate", "sleepAnalysis"]
+ // Run 1: sleep delete throws (absent from succeededThisRun).
+ let pending1 = HealthWriteback.strandedSweepPending(swept: swept, authorized: auth)
+ XCTAssertEqual(pending1, ["restingHeartRate", "sleepAnalysis"])
+ swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["restingHeartRate"])
+ XCTAssertEqual(swept, ["restingHeartRate"])
+ // Run 2: sleep is still pending (not marked swept), so it is retried and succeeds.
+ let pending2 = HealthWriteback.strandedSweepPending(swept: swept, authorized: auth)
+ XCTAssertEqual(pending2, ["sleepAnalysis"], "the failed sleep delete must be retried")
+ swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["sleepAnalysis"])
+ XCTAssertEqual(swept, ["restingHeartRate", "sleepAnalysis"])
+ }
}
diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/physiological_cycles_fahrenheit.csv b/Packages/StrandImport/Tests/StrandImportTests/Resources/physiological_cycles_fahrenheit.csv
new file mode 100644
index 0000000000..f01e289ac8
--- /dev/null
+++ b/Packages/StrandImport/Tests/StrandImportTests/Resources/physiological_cycles_fahrenheit.csv
@@ -0,0 +1,2 @@
+Cycle start time,Cycle end time,Cycle timezone,Recovery score %,Resting heart rate (bpm),Heart rate variability (ms),Skin temp (f),Blood oxygen %,Day Strain,Energy burned (cal),Max HR (bpm),Average HR (bpm),Sleep onset,Wake onset,Sleep performance %,Respiratory rate (rpm),Asleep duration (min),In bed duration (min),Light sleep duration (min),Deep (SWS) duration (min),REM duration (min),Awake duration (min),Sleep efficiency %,Sleep consistency %,Sleep need (min),Sleep debt (min)
+2024-01-02 06:30:00,2024-01-03 06:29:00,UTC+01:00,72,52,68.4,91.58,96.0,12.5,2450,165,68,2024-01-01 23:15:00,2024-01-02 06:30:00,85,14.2,420,455,210,95,115,35,92.3,88.0,480,60
diff --git a/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift
index 7e6aa6d3cf..f1e86a9444 100644
--- a/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift
+++ b/Packages/StrandImport/Tests/StrandImportTests/WhoopExportImporterTests.swift
@@ -94,6 +94,36 @@ final class WhoopExportImporterTests: XCTestCase {
XCTAssertEqual(r1.recoveryScore, 55)
}
+ // MARK: - #1849: Fahrenheit skin-temp import
+
+ /// A Fahrenheit WHOOP export ships `skin_temp_f` instead of `skin_temp_celsius`. The
+ /// importer must convert the °F value to °C on the way in — NOT store it unconverted
+ /// (92.3 °F stored as 92.3 in a °C column reads as a lethal fever and poisons the
+ /// baseline). 91.58 °F = 33.1 °C, the same value the Celsius fixture carries.
+ func testFahrenheitSkinTempIsConvertedToCelsius() throws {
+ let table = CSVTable(data: Fixtures.data("physiological_cycles_fahrenheit.csv"))
+ let rows = WhoopExportImporter().parseCycles(table)
+
+ XCTAssertEqual(rows.count, 1)
+ let r0 = try XCTUnwrap(rows.first)
+ // 91.58 °F → (91.58 − 32) × 5/9 = 33.1 °C — the same stored value the Celsius
+ // fixture produces, so a Fahrenheit import lands byte-identical to a Celsius one.
+ XCTAssertEqual(r0.skinTempCelsius ?? -999, 33.1, accuracy: 0.001,
+ "a Fahrenheit skin_temp_f must be converted to °C on import, not stored as-is")
+ }
+
+ /// A Celsius export (the canonical form) is unaffected — `skin_temp_celsius` is read
+ /// directly and no conversion is applied. This is the regression guard: the fix must
+ /// not change the Celsius path.
+ func testCelsiusSkinTempIsUnchanged() throws {
+ let table = CSVTable(data: Fixtures.data("physiological_cycles.csv"))
+ let rows = WhoopExportImporter().parseCycles(table)
+
+ let r0 = try XCTUnwrap(rows.first)
+ XCTAssertEqual(r0.skinTempCelsius ?? -999, 33.1, accuracy: 0.001,
+ "a Celsius skin_temp_celsius must pass through unchanged")
+ }
+
// MARK: - workouts.csv WITHOUT GPS columns
func testWorkoutsWithoutGPSColumnsStillParse() throws {
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift
index 745da92c2a..f203fb5ec3 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift
@@ -28,36 +28,92 @@ public enum BatteryPackInfo {
/// read via GET_EXTENDED_BATTERY_INFO (98) which reports voltage, NOT a charge %. nil on 5/MG.
public let voltageMv: Int?
+
+ /// Whether this reading is safe to SHOW.
+ ///
+ /// The offsets here are an unvalidated candidate re-derived from two captures, so a wrong one
+ /// would not fail — it would render a confident wrong number, which is the failure this project
+ /// treats as worse than a blank. A fuel gauge is a percentage: anything outside 0...100 means the
+ /// offset moved, and the caller must render nothing rather than the value.
+ ///
+ /// The fork that first shipped this hit exactly that shape from the other direction, reading a
+ /// 24,881 mV "cell voltage" off a 5/MG frame — a 4.0-path field read on a family that does not
+ /// answer it. A gauge that cannot be sanity-checked will eventually be believed.
+ public var displayable: Bool {
+ guard present, let soc = socPct else { return false }
+ return soc >= 0 && soc <= 100
+ }
+
public init(present: Bool, socPct: Double?, serial: String?, btAddr: String?, voltageMv: Int? = nil) {
self.present = present; self.socPct = socPct; self.serial = serial
self.btAddr = btAddr; self.voltageMv = voltageMv
}
}
+ /// Offset of the present flag inside the event-109 payload.
+ public static let eventRecordBase = 4
+
+ /// Where a WHOOP 5/MG EVENT frame's opaque payload begins. Mirrors `Framing.decodeEventWhoop5`.
+ public static let whoop5EventPayloadStart = 16
+
+ /// The uncatalogued 5/MG event that carries the pack record. Named here because `EventNumber` has no
+ /// entry for it — it decodes as `0x6D(109)`.
+ public static let packInfoEvent = 109
+
+ /// Decode the pack RECORD itself, given the offset of its present-flag byte.
+ ///
+ /// Split out from `decode` because the same record reaches us by two entirely different transports: as
+ /// the payload of a `GET_BATTERY_PACK_INFO` (151) COMMAND_RESPONSE, and — hardware-confirmed on WHOOP MG
+ /// fw 50.39.1.0, 2026-09-01 — as the payload of the UNCATALOGUED pushed event `0x6D` (109), which the
+ /// strap volunteers while a pack is attached. Only the framing around the record differs; the fields are
+ /// identical, so they must not be decoded twice.
+ ///
+ /// Layout from `base`: +0 present, +1 BT address (6 B), +7 serial (16 B ASCII, NUL-terminated),
+ /// +23 SoC (u16 little-endian, tenths of a percent). nil when the buffer is too short to hold it.
+ public static func decodeRecord(_ bytes: [UInt8], base: Int) -> Info? {
+ guard base >= 0, bytes.count > base else { return nil }
+ let present = bytes[base] == 1
+ guard present else { return Info(present: false, socPct: nil, serial: nil, btAddr: nil) }
+
+ let btStart = base + 1
+ let serStart = base + 7
+ let socStart = base + 23
+ guard bytes.count >= socStart + 2 else { return nil }
+
+ let btAddr = bytes[btStart.. Info? {
+ decodeRecord(payload, base: eventRecordBase)
+ }
+
+ /// Decode straight from a whole 5/MG EVENT frame, so callers never need to know where the payload
+ /// starts. Callers must first check the event number is `packInfoEvent`; this does not re-check it,
+ /// because the event byte lives in the frame header rather than the record.
+ public static func decodeEventFrame(_ frame: [UInt8]) -> Info? {
+ decodeRecord(frame, base: whoop5EventPayloadStart + eventRecordBase)
+ }
+
/// The response-command byte sits at `cmdOff` (10 on WHOOP 5/MG — the only family with a pack; a 4.0's
/// 6 is accepted only so a caller can pass it, though 4.0 never answers 151). Returns nil when the
/// frame is not a well-formed 151 SUCCESS response. The caller is expected to have CRC-gated the frame
/// (the framing layer already does), as the sibling probes assume.
+ ///
+ /// NOTE this command path is retained for completeness only. On WHOOP MG fw 50.39.1.0 opcode 151 answers
+ /// FAILURE(0) whether or not a pack is attached, so the feature is driven by `decodeEventFrame` instead.
public static func decode(frame: [UInt8], cmdOff: Int = 10) -> Info? {
- // Layout relative to cmdOff, pinned to the captures: +0 resp-cmd (151), +2 result (1 = SUCCESS),
- // +4 present flag, +5 BT address (6 bytes), +11 serial (16-byte ASCII, NUL-terminated),
- // +27 SoC (u16 little-endian, tenths of a percent).
+ // Header, pinned to the captures: +0 resp-cmd (151), +2 result (1 = SUCCESS). The record then starts
+ // at +4, which is what `decodeRecord` expects as its base.
guard cmdOff >= 0, frame.count > cmdOff + 4 else { return nil }
guard Int(frame[cmdOff]) == 151, Int(frame[cmdOff + 2]) == 1 else { return nil }
- let present = frame[cmdOff + 4] == 1
- guard present else { return Info(present: false, socPct: nil, serial: nil, btAddr: nil) }
-
- let btStart = cmdOff + 5
- let serStart = cmdOff + 11
- let socStart = cmdOff + 27
- guard frame.count >= socStart + 2 else { return nil }
-
- let btAddr = frame[btStart..= 0, behind <= t else { return nil }
return behind
}
+
+ /// The BLE seam's decision on a GET_DATA_RANGE COMMAND_RESPONSE: is this a reply to `opcode`, and
+ /// may it move state? A reply is accepted only on the verifier's FULL verdict.
+ ///
+ /// This one reply does not merely report a timestamp: `newestUnix`/`oldestUnix` become the window
+ /// every drained record of the same sync is checked against (#547). A damaged reply that narrows
+ /// the window makes the real records fall through it — the section persists nothing and is acked
+ /// anyway, which is the permanent loss the integrity gate exists to stop. So the gate belongs to
+ /// this decision, not to the seam that happens to host it: as a predicate it is testable off the
+ /// app target, where nothing compiles the seam.
+ ///
+ /// `verdictOK` is the FULL verdict of the single parse the caller already holds, taken lazily so a
+ /// frame that is not this reply costs one byte compare and no parse. The opcode is passed in rather
+ /// than restated here: the command table lives with the caller.
+ public static func acceptsReply(_ frame: [UInt8], cmdOff: Int, opcode: UInt8,
+ verdictOK: @autoclosure () -> Bool) -> Bool {
+ guard cmdOff >= 0, cmdOff < frame.count, frame[cmdOff] == opcode else { return false }
+ return verdictOK()
+ }
+
+ /// True when a GET_DATA_RANGE COMMAND_RESPONSE is the `PENDING(2)` acknowledgement rather than the
+ /// answer. The strap replies twice: a short PENDING ack, then the payload with `SUCCESS(1)`. Framing's
+ /// own result-code table already states it — "2=PENDING precedes SUCCESS on GET_DATA_RANGE
+ /// (hardware-confirmed, #78 fork)" — but `pagesBehind` had no way to tell the two apart, so the ack
+ /// (which carries no ring pointers by construction) decoded to nil and logged as a decode FAILURE once
+ /// per sync.
+ ///
+ /// The result byte sits at `cmdOff + 2`, after the echoed opcode and the origin sequence. A frame too
+ /// short to hold one is not a PENDING ack, so it stays false and the caller keeps its existing
+ /// too-short handling.
+ public static func isPendingResponse(_ frame: [UInt8], cmdOff: Int) -> Bool {
+ guard cmdOff >= 0, cmdOff + 2 < frame.count else { return false }
+ return frame[cmdOff + 2] == 2
+ }
}
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/FrameDiagnostics.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/FrameDiagnostics.swift
new file mode 100644
index 0000000000..2215082b73
--- /dev/null
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/FrameDiagnostics.swift
@@ -0,0 +1,138 @@
+import Foundation
+
+// Diagnosis for the frame-integrity gate: what a rejected frame still tells us, how often each reason
+// occurred, and how a frame is rendered for a human. All pure and app-free, so the counters and the
+// inspector's line are covered by `swift test` rather than only by running the tool.
+
+public extension ParsedFrame {
+ /// Whether the decoder got a PACKET TYPE out of this frame — the parseability question, which is
+ /// NOT the integrity question `ok` answers.
+ ///
+ /// The two used to be the same value, so every diagnostic surface that wanted "did this decode?"
+ /// read `ok`. Now `ok` means "header checksum, payload CRC32 and structural length all agree", and
+ /// reading it as parseability would blank the packet type of exactly the frames a capture exists to
+ /// map: a frame with a flipped header byte still decodes its type, its sequence number and its
+ /// fields, and losing that in the record is losing the evidence.
+ ///
+ /// True when the parse produced a real type name rather than the `INVALID/FRAGMENT` placeholder the
+ /// parsers return for a byte run below the family minimum or without a start-of-frame byte.
+ var isParsable: Bool { typeName != ParsedFrame.unparsableTypeName }
+
+ /// The placeholder type name both parsers use for a byte run they could not read at all.
+ static var unparsableTypeName: String { "INVALID/FRAGMENT" }
+
+ /// True for the frame class that passed the gates BEFORE this change: the payload CRC32 verified,
+ /// but the envelope did not — a wrong header checksum, a declared length below the family minimum,
+ /// a truncated frame or one with trailing bytes.
+ ///
+ /// It is deliberately read off the parse result and needs no second verification: both values are
+ /// already on the frame the consumer was handed.
+ var payloadCRCOKButEnvelopeRejected: Bool { !ok && crcOK == true }
+}
+
+/// Per-reason rejection counters for one connection, plus the ONE extra named counter the hardware run
+/// is read from.
+///
+/// Why a plain per-reason count is not enough (D3): the abort criterion for the hardware run is a
+/// CONJUNCTION — "header checksum or length wrong WHILE the payload CRC32 verifies" — and no single
+/// reason bucket can express it. The length bucket in particular also collects the harmless resyncs
+/// after a lost notification, which were happening before this change too. So
+/// `payloadCRCOKButEnvelopeRejected` is counted separately; it is the class that used to pass, and thus
+/// the cleanest regression signal.
+///
+/// Counted per reason only — NOT additionally per device family. A connection talks to exactly one
+/// strap, so a family dimension would be constant.
+///
+/// A tally asserts only what it observed: an unverifiable payload CRC32 is counted as
+/// `payloadCRCUnverifiable`, never folded into `payloadCRCMismatch`. "Could not be checked" is not
+/// "was wrong".
+public struct FrameRejectTally: Equatable, Sendable {
+ /// How often each reason was the FIRST rule a frame failed. `.none` is never counted.
+ public private(set) var counts: [FrameRejectReason: Int] = [:]
+
+ /// The named class from D3: envelope rejected while the payload CRC32 verified.
+ public private(set) var payloadCRCOKButEnvelopeRejected = 0
+
+ /// The reassembler's monotonic drop count already folded into `.belowMinimumLength`, so repeated
+ /// folds of the same counter cannot double-count.
+ private var absorbedReassemblerDrops = 0
+
+ public init() {}
+
+ /// Count one parse result. Intact frames are ignored, so this can sit on the frame path unguarded.
+ /// Returns the reason recorded, or `.none` when the frame was intact and nothing was counted.
+ @discardableResult
+ public mutating func note(_ parsed: ParsedFrame) -> FrameRejectReason {
+ guard !parsed.ok else { return .none }
+ let reason = parsed.rejectReason
+ counts[reason, default: 0] += 1
+ if parsed.payloadCRCOKButEnvelopeRejected { payloadCRCOKButEnvelopeRejected += 1 }
+ return reason
+ }
+
+ /// Count one frame from the VERIFIER's result rather than from a parse result.
+ ///
+ /// The offload path does not parse its frames at the BLE seam — it hands them straight to the
+ /// Backfiller — so the parse-result overload above never sees them, and the named counter the
+ /// hardware run's abort criterion is read from would stay at zero for exactly the traffic in which
+ /// the permanent loss occurs. `FrameCheck` already carries the verdict, the reason and the payload
+ /// CRC32 outcome, so this counts what was observed and costs no second parse.
+ ///
+ /// It asserts no more than the parse-result overload: `payloadCRCOKButEnvelopeRejected` is raised
+ /// only when the CRC32 was actually computed and verified, never when it could not be checked.
+ @discardableResult
+ public mutating func note(_ check: FrameCheck) -> FrameRejectReason {
+ guard !check.ok else { return .none }
+ counts[check.reason, default: 0] += 1
+ if check.crc32OK == true { payloadCRCOKButEnvelopeRejected += 1 }
+ return check.reason
+ }
+
+ /// Fold a reassembler's `belowMinimumLengthDrops` into the `.belowMinimumLength` bucket.
+ ///
+ /// A byte run the reassembler drops never reaches a parser — and never reaches the evidence-
+ /// preserving reader either — so without this it would vanish with no trace at all. The argument is
+ /// the reassembler's MONOTONIC total; only the growth since the last fold is added, so calling this
+ /// once per notification is correct and idempotent.
+ public mutating func absorbReassemblerDrops(_ monotonicTotal: Int) {
+ guard monotonicTotal > absorbedReassemblerDrops else { return }
+ counts[.belowMinimumLength, default: 0] += monotonicTotal - absorbedReassemblerDrops
+ absorbedReassemblerDrops = monotonicTotal
+ }
+
+ /// How often `reason` was recorded.
+ public func count(_ reason: FrameRejectReason) -> Int { counts[reason] ?? 0 }
+
+ /// Every rejection counted, across all reasons.
+ public var totalRejected: Int { counts.values.reduce(0, +) }
+
+ /// One line naming every reason that actually occurred, in a stable order, or nil when nothing was
+ /// rejected — a per-connection readout, so its caller gates it behind the Test Centre domain.
+ /// Silence when there is nothing to report; no "0 rejections" line to read past.
+ public func summaryLine() -> String? {
+ guard totalRejected > 0 else { return nil }
+ let parts = FrameRejectReason.allCases
+ .filter { $0 != .none && count($0) > 0 }
+ .map { "\($0.rawValue)=\(count($0))" }
+ return "frameReject total=\(totalRejected) " + parts.joined(separator: " ")
+ + " payloadCRCOKButEnvelopeRejected=\(payloadCRCOKButEnvelopeRejected)"
+ }
+}
+
+/// The inspector's one-line rendering of a parsed frame.
+///
+/// It lives in the library rather than in the command-line tool so a test can pin it: the tool is an
+/// executable target with no test target of its own, and the property that matters — a REJECTED frame
+/// still shows its packet type, and shows why it was rejected — is exactly the kind that goes quiet
+/// unnoticed.
+///
+/// `index` and `family` are provenance the caller holds; everything else comes off the parse result.
+public func frameInspectionLine(index: Int, family: DeviceFamily, parsed: ParsedFrame) -> String {
+ let crc = parsed.crcOK.map { $0 ? "ok" : "BAD" } ?? "—"
+ var line = "[\(index)] \(family.rawValue) ok=\(parsed.ok) type=\(parsed.typeName)"
+ line += " seq=\(parsed.seq.map(String.init) ?? "—") crc=\(crc)"
+ // Only on a rejection, and only the reason the verifier actually reported: an intact frame carries
+ // no reason, and printing `reason=none` on every line would train the eye to skip the field.
+ if !parsed.ok { line += " reason=\(parsed.rejectReason.rawValue)" }
+ return line
+}
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift
index 4cd03202ea..011333b599 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift
@@ -82,16 +82,84 @@ public func crc16Modbus(_ bytes: [UInt8], _ from: Int = 0, _ to: Int? = nil) ->
return crc
}
+/// Why a frame failed the envelope check — one value, never optional, so a consumer can report the
+/// cause without verifying or parsing the frame a second time (the parse-once invariant).
+///
+/// `none` is the ONLY value that accompanies a positive verdict. The two payload-CRC cases are kept
+/// apart on purpose: a diagnostic may only assert what it observed, and "we could not compute the
+/// CRC32" is not the same claim as "the CRC32 disagreed".
+public enum FrameRejectReason: String, Codable, Equatable, Sendable, CaseIterable {
+ /// The frame is intact: header checksum, payload CRC32 and the structural length all agree.
+ case none
+ /// No 0xAA start-of-frame byte — this byte run is not a frame at all.
+ case noStartOfFrame
+ /// Fewer bytes than the device family's smallest well-formed frame can have.
+ case belowMinimumLength
+ /// The byte count does not equal the total derived from the declared length field: the frame is
+ /// truncated, or it carries trailing bytes past its own end.
+ case lengthMismatch
+ /// The header checksum (CRC-8 on WHOOP 4.0, CRC-16-Modbus on WHOOP 5.0/MG) disagreed.
+ case headerChecksumMismatch
+ /// The payload CRC32 was computed and disagreed.
+ case payloadCRCMismatch
+ /// The payload CRC32 could not be computed at all. Fail-closed: an unverifiable CRC counts as a
+ /// negative verdict, never as "unknown". The structural rules above make this unreachable for a
+ /// frame that got past them, so it is defence in depth rather than an observed class.
+ case payloadCRCUnverifiable
+}
+
+/// The structural lower bounds a frame must clear before any of its bytes are read as fields.
+///
+/// WHOOP 4.0: `[SOF][len u16][crc8][type][seq][cmd] + >=1 payload byte + [crc32 u32]` = 11 bytes.
+/// WHOOP 5.0/MG: `[SOF][fmt][declLen u16][hdr u16][crc16 u16] + >=1 payload byte + [crc32 u32]` = 13.
+/// Both are the values `E2` fixes; the smallest real frame in the project's captures is exactly 11
+/// bytes (WHOOP 4.0) and 124 bytes (WHOOP 5.0/MG), so neither bound rejects a recorded frame.
+public enum FrameLimits {
+ public static let whoop4MinimumFrameBytes = 11
+ public static let whoop5MinimumFrameBytes = 13
+
+ /// The minimum total frame size for `family`, in bytes.
+ public static func minimumFrameBytes(for family: DeviceFamily) -> Int {
+ switch family {
+ case .whoop4: return whoop4MinimumFrameBytes
+ case .whoop5: return whoop5MinimumFrameBytes
+ }
+ }
+}
+
public struct FrameCheck: Equatable {
+ /// The FULL verdict: start-of-frame, minimum length, exact length, header checksum and payload
+ /// CRC32 together. True only when `reason == .none`.
public let ok: Bool
public let length: Int?
public let crc8OK: Bool?
public let crc32OK: Bool?
- public init(ok: Bool, length: Int? = nil, crc8OK: Bool? = nil, crc32OK: Bool? = nil) {
+ /// Why `ok` is false; `.none` exactly when `ok` is true.
+ public let reason: FrameRejectReason
+ public init(ok: Bool, length: Int? = nil, crc8OK: Bool? = nil, crc32OK: Bool? = nil,
+ reason: FrameRejectReason = .none) {
self.ok = ok
self.length = length
self.crc8OK = crc8OK
self.crc32OK = crc32OK
+ self.reason = reason
+ }
+}
+
+/// Turn the individual outcomes into ONE verdict and ONE reason, in a fixed order: structure first
+/// (a length we cannot trust makes every later read meaningless), then the header checksum, then the
+/// payload CRC32. `crc32OK == nil` is a rejection, not an "unknown" that a consumer might read as a
+/// pass — that tri-state is exactly what let malformed frames drive live state before this change.
+@inline(__always)
+private func frameRejectReason(totalFromLength: Int, actualCount: Int, minimumBytes: Int,
+ headerCRCOK: Bool, crc32OK: Bool?) -> FrameRejectReason {
+ if totalFromLength < minimumBytes { return .belowMinimumLength }
+ if totalFromLength != actualCount { return .lengthMismatch }
+ if !headerCRCOK { return .headerChecksumMismatch }
+ switch crc32OK {
+ case .none: return .payloadCRCUnverifiable
+ case .some(false): return .payloadCRCMismatch
+ case .some(true): return .none
}
}
@@ -106,23 +174,39 @@ private func u32le(_ bytes: [UInt8], _ off: Int) -> UInt32 {
| (UInt32(bytes[off + 2]) << 16) | (UInt32(bytes[off + 3]) << 24)
}
-/// Validate a complete frame envelope and both CRCs.
+/// Validate a complete frame envelope: structure, header checksum and payload CRC32 together.
/// Frame: [0xAA][len u16 LE][crc8(len)][...inner...][crc32 u32 LE], total = len + 4.
+///
+/// A frame is accepted only when it is at least `FrameLimits.whoop4MinimumFrameBytes` long, carries
+/// EXACTLY `len + 4` bytes (so a truncated frame and one with trailing bytes are both rejected), its
+/// CRC-8 over the length field matches, and its CRC32 over the inner record matches. The individual
+/// outcomes stay on the result as diagnostics; `reason` says which rule failed first.
public func verifyFrame(_ frame: [UInt8]) -> FrameCheck {
- if frame.count < 8 || frame[0] != 0xAA {
- return FrameCheck(ok: false)
+ guard frame.first == 0xAA else {
+ return FrameCheck(ok: false, reason: .noStartOfFrame)
+ }
+ guard frame.count >= FrameLimits.whoop4MinimumFrameBytes else {
+ // Too short even to hold an envelope plus one payload byte: no field of it is read, and the
+ // length word it may carry is not worth reporting as a length.
+ return FrameCheck(ok: false, reason: .belowMinimumLength)
}
let length = u16le(frame, 1)
+ let total = length + 4
// Ranged CRCs checksum the frame in place, with no per-frame sub-array allocation.
let crc8OK = crc8(frame, 1, 3) == frame[3]
var crc32OK: Bool? = nil
- // length must cover at least the envelope's inner bytes (mirrors framing.py).
- if 7 <= length && length + 4 <= frame.count {
+ // length must cover at least the envelope's inner bytes (mirrors framing.py). Trailing bytes do
+ // not stop the CRC32 from being computed — that combination (payload CRC right, envelope wrong)
+ // is precisely the class this change stops admitting, so it must stay observable.
+ if 7 <= length && total <= frame.count {
// inner record = frame[4.. FrameCheck {
}
private func verifyFrameWhoop5(_ frame: [UInt8]) -> FrameCheck {
- // Minimum whoop5 frame: 8 header bytes (incl. CRC16) + 4 CRC32 trailer = 12.
- if frame.count < 12 || frame[0] != 0xAA {
- return FrameCheck(ok: false)
+ guard frame.first == 0xAA else {
+ return FrameCheck(ok: false, reason: .noStartOfFrame)
}
- let declaredLength = u16le(frame, 2)
- // declaredLength counts payload + the 4-byte CRC32 trailer (mirrors Goose v5Frames/v5Payload).
- guard declaredLength >= 4 else {
- return FrameCheck(ok: false, length: declaredLength)
+ // Smallest well-formed whoop5 frame: 8 header bytes (incl. CRC16) + 1 payload byte + 4 CRC32.
+ guard frame.count >= FrameLimits.whoop5MinimumFrameBytes else {
+ return FrameCheck(ok: false, reason: .belowMinimumLength)
}
+ // declaredLength counts payload + the 4-byte CRC32 trailer (mirrors Goose v5Frames/v5Payload).
+ let declaredLength = u16le(frame, 2)
let total = declaredLength + 8
// Header CRC16-Modbus over the first 6 bytes, stored LE at frame[6..8]. Ranged, no copy.
@@ -168,8 +252,8 @@ private func verifyFrameWhoop5(_ frame: [UInt8]) -> FrameCheck {
let headerCRCOK = wantHeaderCRC == gotHeaderCRC
var crc32OK: Bool? = nil
- if frame.count >= total {
- // payload spans [8, total-4); CRC32 trailer is the final 4 bytes of the frame.
+ if declaredLength >= 4, total <= frame.count {
+ // payload spans [8, total-4); CRC32 trailer is the final 4 bytes the length declares.
let payloadEnd = total - 4
// payload = frame[8.. FrameCheck {
crc32OK = want == got
}
- let ok = headerCRCOK && (crc32OK ?? false)
+ let reason = frameRejectReason(totalFromLength: total, actualCount: frame.count,
+ minimumBytes: FrameLimits.whoop5MinimumFrameBytes,
+ headerCRCOK: headerCRCOK, crc32OK: crc32OK)
// Report the header outcome through crc8OK so callers have a single header-CRC signal.
- return FrameCheck(ok: ok, length: declaredLength, crc8OK: headerCRCOK, crc32OK: crc32OK)
+ return FrameCheck(ok: reason == .none, length: declaredLength, crc8OK: headerCRCOK,
+ crc32OK: crc32OK, reason: reason)
}
/// Reconstruct a complete frame from a bare payload (data == frame[7:]).
-/// Some captures store only the data portion; rebuild the envelope with a correct zlib
-/// crc32 and a placeholder crc8 byte (0x00). Mirrors framing.py frame_from_payload.
+/// Some captures store only the data portion; rebuild the envelope with a correct zlib crc32 AND a
+/// correct CRC-8 header checksum. Mirrors framing.py frame_from_payload.
+///
+/// The CRC-8 byte used to be a 0x00 placeholder. That was harmless while the gates only asked
+/// whether the payload CRC32 was *demonstrably* wrong, but a frame built this way is rejected the
+/// moment the header checksum counts — so every rebuilt frame is now a frame a strap could have
+/// sent, and the round-trip through `verifyFrame` is positive end to end.
public func frameFromPayload(_ data: [UInt8], type: UInt8, seq: UInt8 = 0, cmd: UInt8 = 0) -> [UInt8] {
let inner: [UInt8] = [type, seq, cmd] + data
let length = inner.count + 4
- var frame: [UInt8] = [0xAA, UInt8(length & 0xFF), UInt8((length >> 8) & 0xFF), 0x00]
+ var frame: [UInt8] = [0xAA, UInt8(length & 0xFF), UInt8((length >> 8) & 0xFF)]
+ frame.append(crc8(frame, 1, 3))
frame.append(contentsOf: inner)
let c = crc32(inner)
frame.append(UInt8(c & 0xFF))
@@ -228,8 +321,9 @@ public func puffinCommandFrame(cmd: UInt8, seq: UInt8, payload: [UInt8] = [0x00]
}
/// Accumulate BLE notification fragments into complete frames.
-/// A complete frame is `length + 4` bytes where length = u16 LE at buf[1..3].
-/// Mirrors framing.py Reassembler.
+/// A complete frame is `length + 4` bytes where length = u16 LE at buf[1..3], and never smaller than
+/// the family minimum in `FrameLimits` — a start-of-frame declaring less is dropped and the stream
+/// resyncs on the next one. Mirrors framing.py Reassembler.
public final class Reassembler {
// Backed by a flat byte buffer plus a read cursor rather than draining off the front. The earlier
// form called buf.removeFirst(n), which shifts the whole tail down on every completed frame, so
@@ -247,6 +341,12 @@ public final class Reassembler {
/// Mirrors the Android `Framing.kt` cap. (Reimplemented from @vulnix0x4's PR #374.)
static let maxFrameBytes = 8192
+ /// How many start-of-frame bytes were dropped because the total length they declared was below
+ /// the family minimum. Such a byte run never reaches a parser, so without this counter it would
+ /// vanish without trace — and one of the readers downstream exists to preserve exactly the
+ /// frames nothing else can read. Monotonic for the lifetime of the reassembler.
+ public private(set) var belowMinimumLengthDrops = 0
+
/// `family` selects the frame-length convention:
/// - WHOOP 4.0 reads a u16 length at `buf[1..3]`, total = `length + 4`.
/// - WHOOP 5.0 ("puffin") reads a u16 declared length at `buf[2..4]` (after the `0xAA` SOF and
@@ -282,6 +382,14 @@ public final class Reassembler {
case .whoop5:
total = (Int(buf[head + 2]) | (Int(buf[head + 3]) << 8)) + 8
}
+ if total < FrameLimits.minimumFrameBytes(for: family) {
+ // A declared total below the family minimum cannot be a frame: emitting it would hand
+ // a parser a byte run whose "inner fields" are its own checksum trailer. Drop this
+ // 0xAA, count it, and resync on the next one — same shape as the ceiling below.
+ belowMinimumLengthDrops += 1
+ head += 1
+ continue
+ }
if total > Reassembler.maxFrameBytes {
// Impossibly large declared length → this 0xAA is garbage. Drop it and resync to the
// next SOF instead of stalling the live stream until a reconnect.
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift
index 677521442c..feec33d453 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift
@@ -18,9 +18,15 @@ public enum HistoricalMeta: Equatable {
/// - For HISTORY_END the metadata post-hook additionally stores in p.parsed:
/// `"unix"` → `.int(unix_seconds)` and `"trim_cursor"` → `.int(trim_value)`
public func classifyHistoricalMeta(_ p: ParsedFrame) -> HistoricalMeta {
- // Integrity gate: only act on a checksum-valid frame. CRC32 is the protocol's only integrity
- // check; without this a malicious/garbled BLE peer could forge HISTORY_END/HISTORY_COMPLETE
- // (advancing the trim cursor + acking the strap to discard data we never durably stored).
+ // Integrity gate: only act on an INTACT frame. `ok` is now the verifier's FULL verdict — header
+ // checksum, payload CRC32 and the structural length together — which is the part that matters
+ // here: a forged HISTORY_END/HISTORY_COMPLETE whose payload CRC32 is right but whose header
+ // checksum or declared length is not used to reach this classifier, and acting on one advances the
+ // trim cursor, which acks the strap to discard data we never durably stored.
+ //
+ // The `crcOK` half of the old two-step check is now redundant for every freshly parsed frame and is
+ // kept only for parse results handed in from elsewhere — a `ParsedFrame` decoded from a capture
+ // written before the verdict widened carries the old constant `ok: true` beside a false `crcOK`.
guard p.ok, p.crcOK != false else { return .other }
guard p.typeName == "METADATA" else { return .other }
guard case .string(let metaName)? = p.parsed["meta_type"] else { return .other }
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift
index 398cf40094..e5f5256519 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift
@@ -78,15 +78,31 @@ public func isPlausibleHistoricalUnix(_ ts: Int, wallNow: Int,
/// archive they are lost forever while the UI reports a clean sync (#77 / #91).
///
/// Console (type-50, `frame[typeIndex] == 0x32`) frames are strap-side debug-log text that decode to
-/// zero rows BY DESIGN and are never returned. 5/MG v26 (raw PPG block, hist_version 26) is also
-/// skipped unconditionally (even on a CRC failure): a v26 record's payload is the optical waveform,
-/// which `extractHistoricalStreams` now persists durably in its OWN stream (`Streams.ppgWaveform` /
-/// WhoopStore's `ppgWaveformSample` table, issue #156 follow-up) whenever it decodes — this reject
-/// archive exists for genuinely-undecodable records, and a decoded v26 record was never one of those.
+/// zero rows BY DESIGN and are never returned. 5/MG v26 (raw PPG block, hist_version 26) is skipped
+/// only while it is INTACT: a v26 record's payload is the optical waveform, which
+/// `extractHistoricalStreams` persists durably in its OWN stream (`Streams.ppgWaveform` / WhoopStore's
+/// `ppgWaveformSample` table, issue #156 follow-up) — but only for a record it accepts. Since the
+/// integrity gate, a v26 record with a broken envelope is dropped by that extraction too, so skipping
+/// it here on the version byte alone would leave it stored nowhere while its section is acked anyway.
/// Only genuine type-47 record frames whose payload would otherwise be silently dropped are returned.
///
/// Used by the Backfiller/BLEManager to archive undecodable history BEFORE acking the trim. Mirrors
/// the Android rejectedHistoricalRecords so one mapping toolchain re-ingests both archives.
+///
+/// EVIDENCE-PRESERVING READER (D8) — its verdict runs the OTHER WAY ROUND, and the difference is
+/// load-bearing. Everywhere else a negative integrity verdict means "do not act on this frame". Here it
+/// means "this frame must be archived", because the strap is about to free it and this archive is the
+/// only durable copy that will exist. Turning this into "act only on a positive verdict", the way the
+/// state-driving gates were turned, would delete exactly the frames it exists to keep.
+///
+/// So the stricter verdict makes the archived set LARGER, never smaller: every frame archived before is
+/// archived still, plus the classes the envelope check now rejects (a wrong header checksum, a declared
+/// length below the family minimum, a truncated frame, trailing bytes). The one way a frame can be lost
+/// relative to before is upstream of here: the reassembler drops a byte run whose declared total is
+/// below the family minimum, so it never reaches any parser. That drop is counted
+/// (`Reassembler.belowMinimumLengthDrops`, folded into `FrameRejectTally`) precisely so it stays visible.
+///
+/// Accepted side effect: on a noisy link the raw archives grow. The existing eviction rule bounds that.
public func rejectedHistoricalRecords(_ rawFrames: [[UInt8]], family: DeviceFamily) -> [[UInt8]] {
// The type byte sits at the inner-record start: frame[4] on WHOOP 4.0, frame[8] on WHOOP 5/MG
// (the puffin envelope is 4 bytes longer). hist_version sits one byte past the type+seq+cmd
@@ -97,7 +113,14 @@ public func rejectedHistoricalRecords(_ rawFrames: [[UInt8]], family: DeviceFami
// Only genuine HISTORICAL_DATA records (47). Console (50) and METADATA frames have a
// different type byte, so they never pass this gate — they are excluded by construction.
guard f.count > typeIndex, Int(f[typeIndex]) == 47 else { return false }
- if family == .whoop5, f.count > versionIndex, Int(f[versionIndex]) == 26 { return false } // v26 PPG: has its own durable stream (ppgWaveform), not this reject archive
+ // v26 PPG: skipped BECAUSE `extractHistoricalStreams` stores it durably in its own waveform
+ // stream (ppgWaveform) — so the skip holds only while that premise does. A REJECTED v26 record
+ // is dropped by the extraction like any other, which leaves it stored nowhere while the section
+ // is acked anyway. Bind the skip to the verdict, not to the version byte alone.
+ if family == .whoop5, f.count > versionIndex, Int(f[versionIndex]) == 26 {
+ let p = parseFrame(f, family: family)
+ return !(p.ok && p.crcOK != false)
+ }
// UNMAPPED LAYOUT (5/MG) — archive UNCONDITIONALLY, whatever it decoded.
//
// The decode-outcome test below is the wrong question for a layout NOOP has no field map for.
@@ -113,7 +136,11 @@ public func rejectedHistoricalRecords(_ rawFrames: [[UInt8]], family: DeviceFami
// banks empty placeholder records at 1 Hz cannot push out the one informative frame either.
if family == .whoop5, isUnmappedWhoop5HistoricalRecord(f) { return true }
let p = parseFrame(f, family: family)
- // Envelope/CRC reject: parse failed outright or the CRC32 trailer mismatched.
+ // NOT INTACT → ARCHIVE. Reading the verdict this way round is the whole point of this reader
+ // (D8): the frame cannot be turned into rows, so its bytes are the only thing left to keep.
+ // With the verdict widened to cover the header checksum and the structural length, this branch
+ // catches strictly MORE frames than it did before — which is the intended direction. The
+ // `crcOK` half stays for exactly that reason: every condition here can only add to the archive.
if !p.ok || p.crcOK == false { return true }
// Unmapped layout: the envelope parsed but no usable biometrics decoded. A record is genuinely
// undecodable only if it has no timestamp, or NEITHER heart rate NOR motion. v25 (issue #30)
@@ -242,6 +269,11 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame],
// #891: packet types that reach `default:` and are dropped. See `Streams.unhandledPacketTypes`.
var unhandledTypes: [String: Int] = [:]
for r in parsed {
+ // `ok` is now the FULL verdict — header checksum, payload CRC32 and structural length — so it
+ // alone rejects everything the two-part check used to. The `crcOK` half is kept because this
+ // function takes parse results from its CALLER, and a `ParsedFrame` decoded from a capture file
+ // written before the verdict widened carries the old constant `ok: true` beside a false `crcOK`.
+ // Dropping it would start deriving rows from those.
if !r.ok || r.crcOK == false { continue }
let p = r.parsed
switch r.typeName {
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift
index aa5e8d6e01..2123d285cb 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift
@@ -11,6 +11,9 @@ public struct DecodedField: Codable, Equatable {
}
public struct ParsedFrame: Codable, Equatable {
+ /// The FULL integrity verdict of `verifyFrame` — header checksum, payload CRC32 and structural
+ /// length together. It means "this frame is intact", NOT "this frame could be parsed": a frame
+ /// with a broken header still carries its decoded `typeName` and `parsed` fields for inspection.
public let ok: Bool
public let typeName: String
public let seq: Int?
@@ -20,45 +23,96 @@ public struct ParsedFrame: Codable, Equatable {
public let rawHex: String
public let fields: [DecodedField]
public let parsed: [String: ParsedValue]
+ /// Why `ok` is false, carried here so a consumer can report the cause from the value it was
+ /// handed — the frame is parsed exactly once and the result threaded on, so a consumer that had
+ /// to verify again to learn the reason would break that invariant.
+ public let rejectReason: FrameRejectReason
+
+ public init(ok: Bool, typeName: String, seq: Int?, cmdName: String?, crcOK: Bool?,
+ lenBytes: Int, rawHex: String, fields: [DecodedField],
+ parsed: [String: ParsedValue], rejectReason: FrameRejectReason = .none) {
+ self.ok = ok
+ self.typeName = typeName
+ self.seq = seq
+ self.cmdName = cmdName
+ self.crcOK = crcOK
+ self.lenBytes = lenBytes
+ self.rawHex = rawHex
+ self.fields = fields
+ self.parsed = parsed
+ self.rejectReason = rejectReason
+ }
+
+ /// Decoding tolerates a MISSING `rejectReason` and defaults it to `.none`. A parse result is
+ /// serialised into capture files and read back from hand-written fixtures; a strictly required
+ /// new key would make every one of those older documents fail to decode.
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ ok = try c.decode(Bool.self, forKey: .ok)
+ typeName = try c.decode(String.self, forKey: .typeName)
+ seq = try c.decodeIfPresent(Int.self, forKey: .seq)
+ cmdName = try c.decodeIfPresent(String.self, forKey: .cmdName)
+ crcOK = try c.decodeIfPresent(Bool.self, forKey: .crcOK)
+ lenBytes = try c.decode(Int.self, forKey: .lenBytes)
+ rawHex = try c.decode(String.self, forKey: .rawHex)
+ fields = try c.decode([DecodedField].self, forKey: .fields)
+ parsed = try c.decode([String: ParsedValue].self, forKey: .parsed)
+ rejectReason = try c.decodeIfPresent(FrameRejectReason.self, forKey: .rejectReason) ?? .none
+ }
}
// MARK: - low-level readers (LE), nil when out of range (mirrors interpreter._read)
-
-@inline(__always) private func readU8(_ f: [UInt8], _ off: Int) -> Int? {
- off + 1 <= f.count ? Int(f[off]) : nil
+//
+// `limit` is the EXCLUSIVE upper bound for a named inner field (D7): the minimum of where the CRC32
+// trailer starts and how many bytes the frame actually has. It is a required argument, not a
+// defaulted one, so adding a field read without deciding its bound does not compile. Callers derive
+// it with `payloadLimit(_:trailerStart:)`, which never returns more than `f.count`, so passing it
+// here is at least as safe as the old bound and never reads a byte that is not there.
+
+@inline(__always) private func readU8(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? {
+ off >= 0 && off + 1 <= limit ? Int(f[off]) : nil
}
-@inline(__always) private func readU16(_ f: [UInt8], _ off: Int) -> Int? {
- off + 2 <= f.count ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil
+@inline(__always) private func readU16(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? {
+ off >= 0 && off + 2 <= limit ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil
}
-@inline(__always) private func readU32(_ f: [UInt8], _ off: Int) -> Int? {
- guard off + 4 <= f.count else { return nil }
+@inline(__always) private func readU32(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? {
+ guard off >= 0, off + 4 <= limit else { return nil }
return Int(f[off]) | (Int(f[off + 1]) << 8) | (Int(f[off + 2]) << 16) | (Int(f[off + 3]) << 24)
}
-@inline(__always) private func readI16(_ f: [UInt8], _ off: Int) -> Int? {
- guard off + 2 <= f.count else { return nil }
+@inline(__always) private func readI16(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? {
+ guard off >= 0, off + 2 <= limit else { return nil }
let raw = UInt16(f[off]) | (UInt16(f[off + 1]) << 8)
return Int(Int16(bitPattern: raw))
}
-@inline(__always) private func readI32(_ f: [UInt8], _ off: Int) -> Int? {
- guard off + 4 <= f.count else { return nil }
- let raw = UInt32(f[off]) | (UInt32(f[off + 1]) << 8) | (UInt32(f[off + 2]) << 16) | (UInt32(f[off + 3]) << 24)
- return Int(Int32(bitPattern: raw))
-}
-
-@inline(__always) private func readF32(_ f: [UInt8], _ off: Int) -> Double? {
- guard off + 4 <= f.count else { return nil }
+@inline(__always) private func readF32(_ f: [UInt8], _ off: Int, _ limit: Int) -> Double? {
+ guard off >= 0, off + 4 <= limit else { return nil }
let bits = UInt32(f[off]) | (UInt32(f[off + 1]) << 8) | (UInt32(f[off + 2]) << 16) | (UInt32(f[off + 3]) << 24)
return Double(Float(bitPattern: bits)) // float32 -> Double is exact, no rounding
}
-/// Read a schema dtype at off; returns the integer value or nil if out of range.
-private func readDType(_ f: [UInt8], _ off: Int, _ dtype: String) -> Int? {
+/// The exclusive upper bound for reading named inner fields out of `frame` (D7).
+///
+/// It is the MINIMUM of the CRC32 trailer's start and the frame's real size — not one or the other.
+/// The trailer start follows from the DECLARED length, which on a truncated frame points past the
+/// last byte we hold, so using it alone would read off the end of the buffer; using only the frame
+/// size is what let a frame at the family minimum have its own checksum trailer decoded as a
+/// sequence number, a command byte or a metadata type. A field counts as present when its start plus
+/// its length does not EXCEED this bound: the smallest real WHOOP 4.0 history frame is 11 bytes with
+/// its trailer at 7, and its metadata type occupies precisely the last payload byte.
+@inline(__always)
+private func payloadLimit(_ frame: [UInt8], trailerStart: Int?) -> Int {
+ guard let trailerStart = trailerStart else { return frame.count }
+ return min(max(0, trailerStart), frame.count)
+}
+
+/// Read a schema dtype at off, bounded by `limit`; nil when the field does not fit.
+private func readDType(_ f: [UInt8], _ off: Int, _ dtype: String, _ limit: Int) -> Int? {
switch dtype {
- case "u8": return readU8(f, off)
- case "u16": return readU16(f, off)
- case "u32": return readU32(f, off)
- case "i16": return readI16(f, off)
+ case "u8": return readU8(f, off, limit)
+ case "u16": return readU16(f, off, limit)
+ case "u32": return readU32(f, off, limit)
+ case "i16": return readI16(f, off, limit)
default: return nil
}
}
@@ -128,20 +182,25 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF
// PuffinCapture, field-asserting tests) do — so on a 1Hz stream or an offload burst this skips a
// per-byte `String(format:)` allocation pass whose result was discarded.
let rawHex = collectFields ? frame.map { String(format: "%02x", $0) }.joined() : ""
- if frame.count < 8 || frame[0] != 0xAA {
+ let check = verifyFrame(frame)
+ // Below the family minimum there is no inner record at all — every offset a field would use
+ // lands in the checksum trailer — so nothing is decoded, not even a packet type.
+ if frame.count < FrameLimits.whoop4MinimumFrameBytes || frame[0] != 0xAA {
return ParsedFrame(ok: false, typeName: "INVALID/FRAGMENT", seq: nil, cmdName: nil,
crcOK: nil, lenBytes: frame.count, rawHex: rawHex,
- fields: [], parsed: [:])
+ fields: [], parsed: [:], rejectReason: check.reason)
}
let schema = loadSchema()
- let check = verifyFrame(frame)
let length = check.length
let crcOK = check.crc32OK
+ // D7: named inner fields come only from payload bytes. On WHOOP 4.0 the CRC32 trailer starts at
+ // the declared length.
+ let limit = payloadLimit(frame, trailerStart: length)
let t = Int(frame[4])
let typeName = schema.typeName(t)
- let seq = Int(frame[5])
+ let seq = readU8(frame, 5, limit)
let fb = FieldBuilder(frame, collectFields: collectFields)
// envelope
@@ -149,17 +208,17 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF
fb.add(1, 2, "length", "frame", value: length.map { .int($0) })
fb.add(3, 1, "crc8", "frame", value: .string(String(format: "0x%02X", frame[3])))
fb.add(4, 1, "packet_type", "frame", value: .string(typeName))
- fb.add(5, 1, "seq", "frame", value: .int(Int(frame[5])))
+ if let seq = seq { fb.add(5, 1, "seq", "frame", value: .int(seq)) }
let spec = schema.packet(forType: t)
if spec == nil {
- fb.add(6, 1, "cmd", "cmd", value: frame.count > 6 ? .int(Int(frame[6])) : nil)
+ fb.add(6, 1, "cmd", "cmd", value: readU8(frame, 6, limit).map { .int($0) })
if let length = length { fb.region(7, length, "payload", "unknown") }
} else {
// static fields from schema
for fld in spec!.fields {
guard let dtype = fld.dtype else { continue }
- guard let val = readDType(frame, fld.off, dtype) else { continue }
+ guard let val = readDType(frame, fld.off, dtype, limit) else { continue }
let value: ParsedValue
if let enumKey = fld.`enum` {
value = .string(schema.enumName(enumKey, val))
@@ -182,12 +241,14 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF
note: check.crc32OK == true ? "OK" : "MISMATCH")
}
- let cmdByte = frame.count > 6 ? Int(frame[6]) : 0
+ let cmdByte = readU8(frame, 6, limit) ?? 0
let cmdName = (t == 35 || t == 36) ? schema.enumName("CommandNumber", cmdByte) : nil
- return ParsedFrame(ok: true, typeName: typeName, seq: seq, cmdName: cmdName,
+ // `ok` is the verifier's full verdict, not a constant: the fields above stay decoded so an
+ // inspector can still read a broken frame, but no consumer may mistake that for integrity.
+ return ParsedFrame(ok: check.ok, typeName: typeName, seq: seq, cmdName: cmdName,
crcOK: crcOK, lenBytes: frame.count, rawHex: rawHex,
- fields: fb.fields, parsed: fb.parsed)
+ fields: fb.fields, parsed: fb.parsed, rejectReason: check.reason)
}
/// #47: the packet type NAME only — NO CRC verify, NO FieldBuilder — for hot-path pre-filters that just
@@ -195,18 +256,25 @@ public func parseFrame(_ frame: [UInt8], collectFields: Bool = false) -> ParsedF
/// multi-minute offload of thousands of type-47 records that only ever act on rare EVENT frames, this skips
/// the redundant decode for the ~99% that aren't. Mirrors `parseFrame`'s family split EXACTLY — the inner
/// type byte is at [4] on WHOOP4 and [8] on 5/MG, with the same lookup each uses (`schema.typeName` /
-/// `canonicalTypeName`). Returns nil for a frame too short / wrong SOF — which `parseFrame` would also mark
-/// INVALID (never "EVENT") — so a pre-filter guarded on `== "EVENT"` is byte-identical to the full-parse
-/// guard.
+/// `canonicalTypeName`), and it uses the same family minimum, so a frame this returns nil for is exactly a
+/// frame `parseFrame` marks INVALID.
+///
+/// What this does NOT tell you is whether the frame is INTACT. It never was a CRC check, but it used to be
+/// interchangeable with the full parse for a caller that only asked "is the type EVENT?", because
+/// `parseFrame` reported success for anything it could read. `parseFrame` now reports the verifier's
+/// verdict, so the two agree on the type NAME and diverge on `ok`: this is a type peek, and a caller that
+/// must not act on a corrupt frame has to parse and check the verdict.
public func frameTypeName(_ frame: [UInt8], family: DeviceFamily) -> String? {
guard frame.first == 0xAA else { return nil }
let schema = loadSchema()
switch family {
case .whoop4:
- guard frame.count >= 8 else { return nil } // parseFrame's `count < 8` INVALID guard
+ // parseFrame's INVALID guard.
+ guard frame.count >= FrameLimits.whoop4MinimumFrameBytes else { return nil }
return schema.typeName(Int(frame[4]))
case .whoop5:
- guard frame.count >= 12 else { return nil } // parseFrameWhoop5's `count < 12` INVALID guard
+ // parseFrameWhoop5's INVALID guard.
+ guard frame.count >= FrameLimits.whoop5MinimumFrameBytes else { return nil }
return canonicalTypeName(Int(frame[8]), schema: schema)
}
}
@@ -233,23 +301,27 @@ public func parseFrame(_ frame: [UInt8], family: DeviceFamily, collectFields: Bo
private func parseFrameWhoop5(_ frame: [UInt8], collectFields: Bool) -> ParsedFrame {
// D#969: gated identically to parseFrame — build the hex only when a consumer reads it.
let rawHex = collectFields ? frame.map { String(format: "%02x", $0) }.joined() : ""
- // Minimum whoop5 frame: 8 header bytes + 1 inner (type) + 4 CRC32 trailer.
- if frame.count < 12 || frame[0] != 0xAA {
+ let check = verifyFrame(frame, family: .whoop5)
+ // Minimum whoop5 frame: 8 header bytes + 1 payload byte + 4 CRC32 trailer. Below that, the type
+ // byte at [8] would be the first byte of the frame's own CRC32 trailer, so nothing is decoded.
+ if frame.count < FrameLimits.whoop5MinimumFrameBytes || frame[0] != 0xAA {
return ParsedFrame(ok: false, typeName: "INVALID/FRAGMENT", seq: nil, cmdName: nil,
crcOK: nil, lenBytes: frame.count, rawHex: rawHex,
- fields: [], parsed: [:])
+ fields: [], parsed: [:], rejectReason: check.reason)
}
let schema = loadSchema()
- let check = verifyFrame(frame, family: .whoop5)
let declaredLength = check.length // payload + 4 (CRC32)
let crcOK = check.crc32OK
// Inner record starts at offset 8: [type][seq][cmd][data…].
let innerStart = 8
+ let payloadEnd = declaredLength.map { ($0 + 8) - 4 } // start of CRC32 trailer
+ // D7: named inner fields come only from payload bytes, bounded by the trailer AND the real size.
+ let limit = payloadLimit(frame, trailerStart: payloadEnd)
let t = Int(frame[innerStart])
let typeName = canonicalTypeName(t, schema: schema)
- let seq = frame.count > innerStart + 1 ? Int(frame[innerStart + 1]) : nil
+ let seq = readU8(frame, innerStart + 1, limit)
let fb = FieldBuilder(frame, collectFields: collectFields)
// envelope
@@ -267,46 +339,47 @@ private func parseFrameWhoop5(_ frame: [UInt8], collectFields: Bool) -> ParsedFr
// byte 8 here vs byte 4 on 4.0, so every field sits at its 4.0 offset + `delta`. Verified on real
// hardware for REALTIME_DATA (type 40) — HR, R-R and the unix timestamp land exactly at +4 (HR
// matched the standard 2A37 profile to ~0.4 bpm). We reuse the 4.0 schema with that shift.
- let cmdByte = frame.count > innerStart + 2 ? Int(frame[innerStart + 2]) : 0
+ let cmdByte = readU8(frame, innerStart + 2, limit) ?? 0
let delta = innerStart - 4 // = 4
- let payloadEnd = declaredLength.map { ($0 + 8) - 4 } // start of CRC32 trailer
let spec = schema.packet(forType: t)
if spec == nil {
fb.add(innerStart + 2, 1, "cmd", "cmd",
- value: frame.count > innerStart + 2 ? .int(cmdByte) : nil)
+ value: readU8(frame, innerStart + 2, limit).map { .int($0) })
if let payloadEnd = payloadEnd, innerStart + 3 < payloadEnd, payloadEnd <= frame.count {
fb.region(innerStart + 3, payloadEnd, "payload", "unknown")
}
} else {
// Static schema fields at the 4.0 offset + delta.
for fld in spec!.fields {
- guard let dtype = fld.dtype, let val = readDType(frame, fld.off + delta, dtype) else { continue }
+ guard let dtype = fld.dtype,
+ let val = readDType(frame, fld.off + delta, dtype, limit) else { continue }
let value: ParsedValue = fld.`enum`.map { .string(schema.enumName($0, val)) } ?? .int(val)
fb.add(fld.off + delta, fld.len, fld.name, fld.cat, value: value, note: fld.note)
}
if spec!.post == "realtime_data" {
// Verified variable-length extension: REALTIME_DATA R-R intervals (rr_count @13+delta,
// intervals @14+delta…), the same shape as 4.0 shifted by +4.
- let rrn = readDType(frame, 13 + delta, "u8") ?? 0
+ let rrn = readDType(frame, 13 + delta, "u8", limit) ?? 0
var rrs: [Int] = []
for i in 0.. 0 {
+ if let v = readDType(frame, off, "u16", limit), v > 0 {
fb.add(off, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms")
rrs.append(v)
}
}
fb.parsed["rr_intervals"] = .intArray(rrs)
} else if spec!.post == "historical_data" {
- decodeWhoop5Historical(frame, fb: fb, payloadEnd: payloadEnd)
+ decodeWhoop5Historical(frame, fb: fb, payloadEnd: payloadEnd, limit: limit)
} else if spec!.post == "metadata" {
- decodeWhoop5Metadata(frame, fb: fb)
+ decodeWhoop5Metadata(frame, fb: fb, limit: limit)
} else if spec!.post == "command_response" {
- decodeWhoop5CommandResponse(frame, fb: fb, schema: schema, payloadEnd: payloadEnd)
+ decodeWhoop5CommandResponse(frame, fb: fb, schema: schema, payloadEnd: payloadEnd,
+ limit: limit)
} else if spec!.post == "event" {
- decodeWhoop5Event(frame, fb: fb, schema: schema)
+ decodeWhoop5Event(frame, fb: fb, schema: schema, limit: limit)
} else if spec!.post == "console_logs" {
- decodeWhoop5ConsoleLogs(frame, fb: fb, payloadEnd: payloadEnd)
+ decodeWhoop5ConsoleLogs(frame, fb: fb, payloadEnd: payloadEnd, limit: limit)
} else if let payloadEnd = payloadEnd, innerStart + 3 < payloadEnd, payloadEnd <= frame.count {
// Other types: static fields decoded above; the remaining variable body is kept raw —
// its 4.0 post-hook awaits per-type 5.0 hardware verification before we apply it at +4.
@@ -326,9 +399,10 @@ private func parseFrameWhoop5(_ frame: [UInt8], collectFields: Bool) -> ParsedFr
let cmdName = (t == 35 || t == 36 || t == PuffinPacketType.puffinCommandResponse)
? schema.enumName("CommandNumber", cmdByte) : nil
- return ParsedFrame(ok: true, typeName: typeName, seq: seq, cmdName: cmdName,
+ // The verifier's verdict, not a constant — see the WHOOP 4.0 parser for the reasoning.
+ return ParsedFrame(ok: check.ok, typeName: typeName, seq: seq, cmdName: cmdName,
crcOK: crcOK, lenBytes: frame.count, rawHex: rawHex,
- fields: fb.fields, parsed: fb.parsed)
+ fields: fb.fields, parsed: fb.parsed, rejectReason: check.reason)
}
/// The WHOOP 5/MG type-47 `hist_version` values `decodeWhoop5Historical` has a REAL field map for.
@@ -367,19 +441,23 @@ public func isUnmappedWhoop5HistoricalRecord(_ frame: [UInt8]) -> Bool {
/// PPG / SpO₂ / skin-temp live further in the 124-byte record but lack on-device ground truth, so
/// they are left as a raw region rather than guessed (project rule: real captures, never invented
/// offsets).
-private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadEnd: Int?) {
- let version = frame.count > 9 ? Int(frame[9]) : -1
+private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadEnd: Int?,
+ limit: Int) {
+ // The layout version is the inner record's seq slot: a named inner field, so it is bounded like
+ // any other. Out of bounds it stays -1, which routes the record to the unmapped branch below.
+ let version = readU8(frame, 9, limit) ?? -1
fb.parsed["hist_version"] = .int(version)
- fb.add(9, 1, "hist_version", "meta", value: .int(version))
+ if version >= 0 { fb.add(9, 1, "hist_version", "meta", value: .int(version)) }
// One dispatch, one list: every `case` here must appear in `mappedWhoop5HistoricalVersions` and
// vice versa, because that set is what decides whether a record gets archived raw. A `switch`
// rather than the old if-chain so the mapped versions are readable in one place.
switch version {
case 26:
- decodeWhoop5HistoricalV26(frame, fb: fb)
+ decodeWhoop5HistoricalV26(frame, fb: fb, limit: limit)
return
case 20, 21:
- decodeWhoop5HistoricalV2021(frame, fb: fb, version: version, payloadEnd: payloadEnd)
+ decodeWhoop5HistoricalV2021(frame, fb: fb, version: version, payloadEnd: payloadEnd,
+ limit: limit)
return
case 18:
break // falls through to the v18 field decode below
@@ -391,23 +469,23 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
}
return
}
- if let idx = readDType(frame, 11, "u32") {
+ if let idx = readDType(frame, 11, "u32", limit) {
// A per-record counter: +1 every record and independent of unix (it advances across gaps);
// observed identical on two straps. @11 is only the low byte — read the full u32 LE.
fb.add(11, 4, "record_index", "meta", value: .int(idx), note: "per-record counter")
}
- if let unix = readDType(frame, 15, "u32") {
+ if let unix = readDType(frame, 15, "u32", limit) {
fb.add(15, 4, "unix", "time", value: .int(unix), note: "real unix seconds")
}
- if let hr = readDType(frame, 22, "u8") {
+ if let hr = readDType(frame, 22, "u8", limit) {
fb.add(22, 1, "heart_rate", "hr", value: .int(hr), note: "bpm")
}
- let rrn = readDType(frame, 23, "u8") ?? 0
+ let rrn = readDType(frame, 23, "u8", limit) ?? 0
fb.add(23, 1, "rr_count", "rr", value: .int(rrn))
var rrs: [Int] = []
for i in 0.. 0 {
+ if let v = readDType(frame, off, "u16", limit), v > 0 {
fb.add(off, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms")
rrs.append(v)
}
@@ -415,7 +493,7 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
fb.parsed["rr_intervals"] = .intArray(rrs)
// Bytes adjacent to the HR/R-R fields, read off real frames: @36 is a FLAG byte and @37 a duplicate
// heart rate — not the two halves of one fixed-point HR (see below); the others are raw.
- if let v = readDType(frame, 33, "u8") {
+ if let v = readDType(frame, 33, "u8", limit) {
fb.add(33, 1, "cardiac_flags", "cardiac", value: .int(v), note: "raw byte near the HR fields")
}
// @36 was read as the low half of a u16 `hr_fixed_8_8` with bpm = value/256. Over 18,650 real v18
@@ -426,65 +504,65 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
// this flag byte over 256, so the residual is a flat +0.504 ± 0.189. Bit 7 reads as a VALIDITY bit:
// with it clear (n=748) rr_count == 0 in 70.32% of records vs 19.82% with it set, and the @108/@109
// sentinel fires in 69.65% vs 1.32%. The remaining bits are unpinned, so the byte ships raw.
- if let v = readDType(frame, 36, "u8") {
+ if let v = readDType(frame, 36, "u8", limit) {
fb.add(36, 1, "hr_quality_flags", "hr", value: .int(v),
note: "flag byte (raw); bit7 = HR/R-R valid, bit4 never set; NOT a fixed-point HR")
}
// @37 duplicates heart_rate@22 — equal in 99.575% of records (18,523/18,602), differing only by
// -6…+2, and it only tracks HR while @36 bit7 is set (99.74% exact vs 94.12% when clear).
- if let v = readDType(frame, 37, "u8") {
+ if let v = readDType(frame, 37, "u8", limit) {
fb.add(37, 1, "heart_rate_alt", "hr", value: .int(v),
note: "bpm; duplicate of heart_rate@22 (99.6% exact); trust only when hr_quality_flags bit7 is set")
}
- if let v = readDType(frame, 38, "u16") {
+ if let v = readDType(frame, 38, "u16", limit) {
fb.add(38, 2, "rr_packed", "rr", value: .int(v), note: "raw u16 near the R-R fields; meaning not pinned")
}
- if let v = readDType(frame, 40, "u8") {
+ if let v = readDType(frame, 40, "u8", limit) {
fb.add(40, 1, "cardiac_status", "cardiac", value: .int(v), note: "raw status-like byte near the HR fields")
}
for (name, off) in [("gravity_x", 45), ("gravity_y", 49), ("gravity_z", 53)] {
- if let d = readF32(frame, off) {
+ if let d = readF32(frame, off, limit) {
fb.add(off, 4, name, "accel", value: .double(d), note: "g")
}
}
// Per-second fields beyond HR/gravity, each gated to a physically-real range and cross-validated
// against real v18 frames (worn vs off-wrist), so a wrong offset on an unmapped layout stores
// nothing rather than garbage (the data is the arbiter).
- if let d = readF32(frame, 41), d.isFinite, (0...8).contains(d) {
+ if let d = readF32(frame, 41, limit), d.isFinite, (0...8).contains(d) {
fb.add(41, 4, "dynamic_acceleration", "accel", value: .double(d), note: "g, gravity-removed magnitude")
}
- if let raw = readDType(frame, 57, "u16") {
+ if let raw = readDType(frame, 57, "u16", limit) {
// Cumulative motion/step counter — monotonic across a stream (validated downstream), no midnight
// reset. Single-frame value is unbounded so it carries no physical gate here.
fb.add(57, 2, "step_motion_counter", "activity", value: .int(raw), note: "cumulative motion counter")
}
- if let cad = readDType(frame, 59, "u8") {
+ if let cad = readDType(frame, 59, "u8", limit) {
// A per-step cadence-like byte between the step counter and @63: never 0, and lower when moving
// faster (still > walk > run in the data). Raw — no unit asserted.
fb.add(59, 1, "step_cadence", "activity", value: .int(cad), note: "cadence-like byte (raw)")
}
- if let wear = readDType(frame, 63, "u8"), (0...2).contains(wear) {
+ if let wear = readDType(frame, 63, "u8", limit), (0...2).contains(wear) {
fb.add(63, 1, "motion_wear_quality", "quality", value: .int(wear), note: "0=still/good, 1, 2=poor contact")
}
// @63 also reads as a small validated ACTIVITY-CLASS enum (community finding, #316): 0=still, 1=walk,
// 2=run, 0xFF=invalid. A lightweight, no-cloud per-record activity readout that rides alongside the
// step counter. Only the four known codes are surfaced — anything else (incl. 0xFF) stores nothing so
// an unmapped firmware can't inject garbage.
- if let cls = readDType(frame, 63, "u8"), cls == 0 || cls == 1 || cls == 2 {
+ if let cls = readDType(frame, 63, "u8", limit), cls == 0 || cls == 1 || cls == 2 {
fb.add(63, 1, "activity_class", "activity", value: .int(cls), note: "0=still, 1=walk, 2=run (0xFF=invalid)")
}
// Two auxiliary thermal channels just before skin_temp. Each is a signed i16 whose value/10 reads as
// °C, tracks skin_temp@73 closely (corr ~0.92 and ~0.97 across the captured corpus) and follows the
// same diurnal curve. Gated to a plausible thermal range so a wrong offset stores nothing.
- if let v = readI16(frame, 69), (0...60).contains(Double(v) / 10.0) {
+ if let v = readI16(frame, 69, limit), (0...60).contains(Double(v) / 10.0) {
fb.add(69, 2, "temp_aux_1_raw", "temp", value: .int(v),
note: "secondary temperature channel; °C = value/10; tracks skin_temp (corr ~0.92) with the same diurnal curve")
}
- if let v = readI16(frame, 71), (0...60).contains(Double(v) / 10.0) {
+ if let v = readI16(frame, 71, limit), (0...60).contains(Double(v) / 10.0) {
fb.add(71, 2, "temp_aux_2_raw", "temp", value: .int(v),
note: "secondary temperature channel; °C = value/10; tracks skin_temp (corr ~0.97) with the same diurnal curve")
}
- if let raw = readDType(frame, 73, "u16") {
+ if let raw = readDType(frame, 73, "u16", limit) {
// Skin temperature from a digital skin-temperature sensor. Emitted as the RAW u16 register
// (`skin_temp_raw`, consumed by the decode-features store) to stay scale-agnostic; °C = raw/100
// is the divisor that yields physiological worn skin temps (median ~34 °C across two straps;
@@ -498,20 +576,20 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
note: "raw register; °C = raw/100 (≈30.6 worn / ~22.5 ambient; on-wrist warming curve)")
}
}
- if let raw = readDType(frame, 75, "u16") {
+ if let raw = readDType(frame, 75, "u16", limit) {
// A 16-bit status word. NOT a deep-sleep marker: across ~258k records its low nibble is 0 and it
// occurs as often awake as asleep (the community "80 = deep" reading is a misread).
fb.add(75, 2, "status_word", "status", value: .int(raw), note: "packed status word; not deep-sleep")
}
- if let v = readDType(frame, 77, "u16") {
+ if let v = readDType(frame, 77, "u16", limit) {
fb.add(77, 2, "status_word_1", "status", value: .int(v),
note: "raw; near-static sibling of status_word@75 (low nibble = 1)")
}
- if let v = readDType(frame, 79, "u16") {
+ if let v = readDType(frame, 79, "u16", limit) {
fb.add(79, 2, "status_word_2", "status", value: .int(v),
note: "raw; sibling of @75 (low nibble = 2)")
}
- if let sb = readDType(frame, 81, "u8") {
+ if let sb = readDType(frame, 81, "u8", limit) {
// High nibble (bits 4-5) tracks a scored night: 0 wake / 1 still / 2 asleep / 3 up; low nibble =
// sub-flags. Deep/REM/light are computed off-band, not present here.
let state = (sb >> 4) & 3
@@ -529,7 +607,7 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
fb.add(81, 1, "sleep_state_byte", "sleep", value: .int(sb),
note: "the RAW @81 flag byte, all 8 bits (b0-1 onwrist, b2-3 wake_quality, b4-5 sleep_state, b6-7 reserved)")
}
- if let v = readDType(frame, 82, "u8") {
+ if let v = readDType(frame, 82, "u8", limit) {
fb.add(82, 1, "aux_byte_82", "status", value: .int(v),
note: "raw; nonzero only while sleep_state = asleep; tri-mode, see spo2_candidate_82")
// #103 SpO2 candidate: a decompile-sourced decode (gen5.rs `spo2_pct`, reimplemented here as a
@@ -569,7 +647,7 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
// unremarkably while worn (@106 in 10 records, @107 in 103). Magnitudes are device-specific (102–255 /
// 119–247 on the R22 strap vs 20–66 / 34–81 on another), so no scale is asserted.
for (name, off) in [("optical_baseline_a", 106), ("optical_baseline_b", 107)] {
- if let v = readDType(frame, off, "u8") {
+ if let v = readDType(frame, off, "u8", limit) {
fb.add(off, 1, name, "optical", value: .int(v),
note: "u8 optical/ADC baseline channel; wanders overnight, 0 = off-wrist; raw, not pinned")
}
@@ -586,15 +664,15 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
// and predicts the band's own beat-detection failure (rr_count == 0) at 79.44% vs 19.40% — a 4.09×
// lift that SURVIVES holding motion constant (4.11× within dyn_acc < 0.009 g), where shuffled and
// circular-shift nulls all sit at ~1.0×. It is the same quality condition @36 bit7 reports.
- if let a = readDType(frame, 108, "u8") {
+ if let a = readDType(frame, 108, "u8", limit) {
fb.add(108, 1, "optical_amp_a", "optical", value: .int(a),
note: "paired optical channel A (≈ optical_amp_b@109); tracks motion, not HR; 128 = record-level signal-quality sentinel; raw")
}
- if let b = readDType(frame, 109, "u8") {
+ if let b = readDType(frame, 109, "u8", limit) {
fb.add(109, 1, "optical_amp_b", "optical", value: .int(b),
note: "paired optical channel B (see optical_amp_a@108); 128 = record-level sentinel, always together with @108; raw")
}
- if let d = readF32(frame, 113), d.isFinite {
+ if let d = readF32(frame, 113, limit), d.isFinite {
// A float32 at @113 (observed range ~ -5.3…0, 0 = unset); purpose unknown, carried raw.
fb.add(113, 4, "unknown_f32_113", "aux", value: .double(d), note: "float32, purpose unknown")
}
@@ -624,28 +702,28 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE
/// The samples are raw AC-coupled ADC counts — PPG has no absolute unit — so they are exposed verbatim
/// as `ppg_waveform` with NO invented scale. The bytes before [27] (header + a block index) and the
/// footer after [75] are not mapped; SpO₂/skin-temp have no internal proxy and are left untouched.
-private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder) {
+private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder, limit: Int) {
// @21 is a small per-burst counter (`burst_index`), NOT the optical channel (PR#553). An earlier read
// labelled it `ppg_channel` and gated it 1…26 because our two original fixtures happened to read 1 and
// 2 — but a later capture read 65, far outside any 26-channel sweep, so @21 is not a stable channel id.
// Re-surfaced as the neutral `burst_index` (gated bi > 0, the observed always-set sentinel) with NO
// channel/LED semantics claimed; the physical optical-channel mapping is unproven and left unasserted.
- if let bi = readDType(frame, 21, "u8"), bi > 0 {
+ if let bi = readDType(frame, 21, "u8", limit), bi > 0 {
fb.add(21, 1, "burst_index", "ppg", value: .int(bi),
note: "per-burst counter (raw); NOT a channel id")
}
// record_index@11 (PR#563): the same monotonic lifetime per-record counter the v18/v20/v21 records
// carry at @11 — +1 per record, independent of unix (advances across gaps). The only @11+ v26 field
// proven by behaviour; everything else below stays raw/neutral.
- if let idx = readDType(frame, 11, "u32") {
+ if let idx = readDType(frame, 11, "u32", limit) {
fb.add(11, 4, "record_index", "meta", value: .int(idx), note: "monotonic lifetime record index")
}
- if let unix = readDType(frame, 15, "u32") {
+ if let unix = readDType(frame, 15, "u32", limit) {
fb.add(15, 4, "unix", "time", value: .int(unix), note: "real unix seconds")
}
var samples: [Int] = []
for off in stride(from: 27, to: 75, by: 2) {
- guard let v = readI16(frame, off) else { break }
+ guard let v = readI16(frame, off, limit) else { break }
samples.append(v)
}
if !samples.isEmpty {
@@ -659,7 +737,7 @@ private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder) {
// waveform block; @75/@79/@81/@82 trail it. (`@27…@75` is the proven waveform handled above.)
for (name, off) in [("raw_u8_19", 19), ("raw_u8_23", 23), ("raw_u8_25", 25),
("raw_u8_75", 75), ("raw_u8_79", 79), ("raw_u8_81", 81), ("raw_u8_82", 82)] {
- if let v = readDType(frame, off, "u8") {
+ if let v = readDType(frame, off, "u8", limit) {
fb.add(off, 1, name, "raw", value: .int(v), note: "raw byte @\(off); meaning not pinned")
}
}
@@ -686,14 +764,15 @@ private func decodeWhoop5HistoricalV26(_ frame: [UInt8], fb: FieldBuilder) {
/// but its measurement wavelengths and channel geometry remain OPEN, so those channels stay neutrally
/// named. This layer exposes raw i16 (v21) or sign-extended i32 (v20) arrays; `Whoop5RawImu.decode`
/// applies the v21 physical scales.
-private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, version: Int, payloadEnd: Int?) {
- if frame.count > 10 {
- fb.add(10, 1, "layout_marker", "meta", value: .int(Int(frame[10])))
+private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, version: Int,
+ payloadEnd: Int?, limit: Int) {
+ if let marker = readU8(frame, 10, limit) {
+ fb.add(10, 1, "layout_marker", "meta", value: .int(marker))
}
- if let idx = readU32(frame, 11) {
+ if let idx = readU32(frame, 11, limit) {
fb.add(11, 4, "record_index", "meta", value: .int(idx), note: "monotonic lifetime record index")
}
- if let unix = readU32(frame, 15) {
+ if let unix = readU32(frame, 15, limit) {
fb.add(15, 4, "unix", "time", value: .int(unix), note: "real unix seconds")
}
if version == 21 {
@@ -711,7 +790,7 @@ private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, ver
for (name, start) in channels {
var samples: [Int] = []
for i in 0..<100 {
- guard let v = readI16(frame, start + i * 2) else { break }
+ guard let v = readI16(frame, start + i * 2, limit) else { break }
samples.append(v)
}
if samples.count == 100 {
@@ -767,10 +846,10 @@ private func decodeWhoop5HistoricalV2021(_ frame: [UInt8], fb: FieldBuilder, ver
/// drive the `HISTORICAL_DATA_RESULT` ack. Offsets are the 4.0 metadata post-hook positions + 4,
/// verified on real WHOOP 5 HISTORY_END frames (trim decodes consistently across a whole capture).
/// `end_data` to echo back in the ack is `frame[21..29]` (trim u32 + next u32).
-private func decodeWhoop5Metadata(_ frame: [UInt8], fb: FieldBuilder) {
- if let unix = readDType(frame, 11, "u32") { fb.add(11, 4, "unix", "time", value: .int(unix)) }
- if let ss = readDType(frame, 15, "u16") { fb.add(15, 2, "subsec", "time", value: .int(ss)) }
- if let trim = readDType(frame, 21, "u32") {
+private func decodeWhoop5Metadata(_ frame: [UInt8], fb: FieldBuilder, limit: Int) {
+ if let unix = readDType(frame, 11, "u32", limit) { fb.add(11, 4, "unix", "time", value: .int(unix)) }
+ if let ss = readDType(frame, 15, "u16", limit) { fb.add(15, 2, "subsec", "time", value: .int(ss)) }
+ if let trim = readDType(frame, 21, "u32", limit) {
fb.add(21, 4, "trim_cursor", "meta", value: .int(trim), note: "ack with this to advance")
}
}
@@ -790,9 +869,10 @@ public func whoop5HistoricalAckFrame(endData: [UInt8], seq: UInt8) -> [UInt8] {
/// mapped from a real WHOOP 5 capture (firmware 50.38.1.0), not ported on faith. Commands that return
/// a short stub on this firmware (REPORT_VERSION_INFO / GET_EXTENDED_BATTERY_INFO) or aren't served
/// (GET_CLOCK — unneeded, since realtime + historical carry real unix) are intentionally left undecoded.
-private func decodeWhoop5CommandResponse(_ frame: [UInt8], fb: FieldBuilder, schema: Schema, payloadEnd: Int?) {
+private func decodeWhoop5CommandResponse(_ frame: [UInt8], fb: FieldBuilder, schema: Schema,
+ payloadEnd: Int?, limit: Int) {
guard let payloadEnd = payloadEnd, 11 < payloadEnd, payloadEnd <= frame.count else { return }
- let respCmd = Int(frame[10])
+ guard let respCmd = readU8(frame, 10, limit) else { return }
let name = schema.enumName("CommandNumber", respCmd) // e.g. "GET_BATTERY_LEVEL(26)"
let pay = Array(frame[11.. Int? { off + 1 <= f.count ? Int(f[off]) : nil }
-private func u16(_ f: [UInt8], _ off: Int) -> Int? {
- off + 2 <= f.count ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil
+/// The exclusive upper bound for reading a named inner field out of `frame` (D7), computed exactly as
+/// the interpreter's own `payloadLimit`: the MINIMUM of where the CRC32 trailer starts and how many
+/// bytes the frame actually has.
+///
+/// The post-hooks run on the parse path and read the schema-driven fields, so they need the same bound
+/// the interpreter applies to the fields it reads itself — clamping only against the frame size lets a
+/// frame near the family minimum have its own checksum trailer decoded as a metadata value. Neither
+/// half may be dropped: the trailer start follows from the DECLARED length, which on a truncated frame
+/// points past the last byte we hold, and reading to it alone would run off the end of the buffer.
+/// A field counts as present when its start plus its length does not EXCEED this bound.
+///
+/// On WHOOP 4.0 — the only family whose parse dispatches through this registry — the trailer starts at
+/// the declared length, which is exactly the `length` argument every hook is handed.
+private func payloadLimit(_ frame: [UInt8], trailerStart: Int?) -> Int {
+ guard let trailerStart = trailerStart else { return frame.count }
+ return min(max(0, trailerStart), frame.count)
+}
+
+// Local LE readers mirroring interpreter._read (nil when out of range). `limit` is the D7 payload bound
+// from `payloadLimit`; it is a required argument, never defaulted, so a field read added later cannot
+// silently skip the decision about where its bytes may come from.
+private func u8(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? {
+ off >= 0 && off + 1 <= limit ? Int(f[off]) : nil
+}
+private func u16(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? {
+ off >= 0 && off + 2 <= limit ? Int(f[off]) | (Int(f[off + 1]) << 8) : nil
}
-private func u32(_ f: [UInt8], _ off: Int) -> UInt32? {
- guard off + 4 <= f.count else { return nil }
+private func u32(_ f: [UInt8], _ off: Int, _ limit: Int) -> UInt32? {
+ guard off >= 0, off + 4 <= limit else { return nil }
return UInt32(f[off]) | (UInt32(f[off + 1]) << 8) | (UInt32(f[off + 2]) << 16) | (UInt32(f[off + 3]) << 24)
}
/// signed 24-bit little-endian (mirrors interpreter._read "s24"); nil when out of range.
-private func s24(_ f: [UInt8], _ off: Int) -> Int? {
- guard off + 3 <= f.count else { return nil }
+private func s24(_ f: [UInt8], _ off: Int, _ limit: Int) -> Int? {
+ guard off >= 0, off + 3 <= limit else { return nil }
let v = Int(f[off]) | (Int(f[off + 1]) << 8) | (Int(f[off + 2]) << 16)
return (v & 0x800000) != 0 ? v - 0x1000000 : v
}
/// IEEE-754 float32 LE -> Double (exact, NO rounding). nil when out of range.
-private func f32(_ f: [UInt8], _ off: Int) -> Double? {
- guard let bits = u32(f, off) else { return nil }
+private func f32(_ f: [UInt8], _ off: Int, _ limit: Int) -> Double? {
+ guard let bits = u32(f, off, limit) else { return nil }
return Double(Float(bitPattern: bits))
}
/// Read an unsigned integer dtype (u8/u16/u32) as Int; nil when out of range.
-private func readHistInt(_ f: [UInt8], _ off: Int, _ dtype: String) -> Int? {
+private func readHistInt(_ f: [UInt8], _ off: Int, _ dtype: String, _ limit: Int) -> Int? {
switch dtype {
- case "u8": return u8(f, off)
- case "u16": return u16(f, off)
- case "u32": return u32(f, off).map { Int($0) }
+ case "u8": return u8(f, off, limit)
+ case "u16": return u16(f, off, limit)
+ case "u32": return u32(f, off, limit).map { Int($0) }
default: return nil
}
}
-/// Read `count` signed i16 LE starting at off, clamping count to the available bytes
+/// Read `count` signed i16 LE starting at off, clamping count to the readable payload bytes
/// (mirrors interpreter._i16_block).
-private func i16Block(_ frame: [UInt8], _ off: Int, _ count: Int) -> [Int] {
+private func i16Block(_ frame: [UInt8], _ off: Int, _ count: Int, _ limit: Int) -> [Int] {
+ guard off >= 0 else { return [] }
var n = count
- if off + n * 2 > frame.count {
- n = max(0, (frame.count - off) / 2)
+ if off + n * 2 > limit {
+ n = max(0, (limit - off) / 2)
}
guard n > 0 else { return [] }
var out: [Int] = []
@@ -104,13 +127,17 @@ private let utcRangeFormatter: DateFormatter = {
}()
func registerPostHooks() {
- postHooks["realtime_data"] = { fb, frame, _, _ in
- let rrn = u8(frame, 13) ?? 0
+ postHooks["realtime_data"] = { fb, frame, length, _ in
+ let limit = payloadLimit(frame, trailerStart: length)
+ // D7: the R-R count is a named inner field, so it comes from payload bytes only. On a frame
+ // whose record is cut short, the byte at 13 is part of the CRC32 trailer — reading it as a
+ // count made the trailer dictate how many "intervals" to decode out of the trailer itself.
+ let rrn = u8(frame, 13, limit) ?? 0
var rrs: [Int] = []
for i in 0.. 0 {
+ if let v = u16(frame, 14 + i * 2, limit), v > 0 {
fb.add(14 + i * 2, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms")
rrs.append(v)
}
@@ -119,7 +146,8 @@ func registerPostHooks() {
}
postHooks["event"] = { fb, frame, length, schema in
- let evVal = frame.count > 6 ? Int(frame[6]) : nil
+ let limit = payloadLimit(frame, trailerStart: length)
+ let evVal = u8(frame, 6, limit)
let evName = evVal.flatMap { schema.enums["EventNumber"]?[String($0)] }
guard let length = length else { return }
if evName == "BATTERY_LEVEL" {
@@ -128,18 +156,18 @@ func registerPostHooks() {
// u32 event_timestamp@8). Emitted ~every 8 min → a DENSE battery series.
// soc% = u16@17/10 · mV = u16@21 · charging = u8@26 bit0
fb.region(7, length, "BATTERY_LEVEL payload", "battery", note: "soc@17(/10) mv@21 charge@26")
- if let raw = u16(frame, 17), raw <= 1100 {
+ if let raw = u16(frame, 17, limit), raw <= 1100 {
fb.parsed["battery_pct"] = .double(Double(raw) / 10)
}
- if let mv = u16(frame, 21), (3000...4300).contains(mv) {
+ if let mv = u16(frame, 21, limit), (3000...4300).contains(mv) {
fb.parsed["battery_mV"] = .int(mv)
}
- if let ch = u8(frame, 26), ch <= 1 {
+ if let ch = u8(frame, 26, limit), ch <= 1 {
fb.parsed["battery_charging"] = .int(ch & 1)
}
} else if evName == "EXTENDED_BATTERY_INFORMATION" {
// Not decoded by the WHOOP app; keep the heuristic mV scan only.
- let payEnd = min(length, frame.count)
+ let payEnd = limit
guard 7 < payEnd else { return }
let pay = Array(frame[7..= 2 {
fb.add(8, 1, "result", "cmd", value: .string(schema.enumName("CommandResult", Int(pay[1]))))
}
- let cmd = frame.count > 6 ? Int(frame[6]) : nil
+ let cmd = u8(frame, 6, payEnd)
let name = cmd.flatMap { schema.enums["CommandNumber"]?[String($0)] }
switch name {
case "GET_BATTERY_LEVEL" where pay.count >= 4:
@@ -229,6 +259,7 @@ func registerPostHooks() {
postHooks["raw_data"] = { fb, frame, length, schema in
guard let length = length else { return }
+ let limit = payloadLimit(frame, trailerStart: length)
let spec = schema.packet(forType: Int(frame[4]))
let dataLen = length - 7
guard let variant = spec?.variants[String(dataLen)] else {
@@ -241,20 +272,20 @@ func registerPostHooks() {
let rrFirstOff = variant.rrFirstOff,
let samples = variant.samples,
let tailFrom = variant.tailFrom else { return }
- let hr = u8(frame, hrOff)
- let rrn = u8(frame, rrCountOff) ?? 0
+ let hr = u8(frame, hrOff, limit)
+ let rrn = u8(frame, rrCountOff, limit) ?? 0
fb.add(hrOff, 1, "heart_rate", "hr", value: hr.map { .int($0) }, note: "bpm")
fb.add(rrCountOff, 1, "rr_count", "rr", value: .int(rrn))
var rrVals: [Int] = []
for i in 0..= 79 {
- if let unix = u32(frame, 11) {
+ if let unix = u32(frame, 11, limit) {
fb.add(11, 4, "unix", "time", value: .int(Int(unix)), note: "real unix seconds")
fb.parsed["unix"] = .int(Int(unix))
}
func grav(_ off: Int) -> Double? {
- guard let u = u16(frame, off) else { return nil }
+ guard let u = u16(frame, off, limit) else { return nil }
return Double(u >= 32768 ? u - 65536 : u) / 16384.0 // i16 LE, ±2 g full-scale
}
if let gx = grav(73), let gy = grav(75), let gz = grav(77) {
@@ -356,14 +391,14 @@ func registerPostHooks() {
let value: ParsedValue
switch dtype {
case "u8", "u16", "u32":
- guard let v = readHistInt(frame, fld.off, dtype) else { continue }
+ guard let v = readHistInt(frame, fld.off, dtype, limit) else { continue }
if let enumKey = fld.`enum` {
value = .string(schema.enumName(enumKey, v))
} else {
value = .int(v)
}
case "f32":
- guard let d = f32(frame, fld.off) else { continue }
+ guard let d = f32(frame, fld.off, limit) else { continue }
value = .double(d) // NO rounding — float32->Double is exact.
default:
continue
@@ -375,7 +410,7 @@ func registerPostHooks() {
let rrn = fb.parsed["rr_count"]?.intValue ?? 0
for i in 0..= 14 {
@@ -426,7 +464,9 @@ func registerPostHooks() {
guard let length = length else { return }
var txt = ""
let lo = 11
- let hi = length - 1
+ // The log text ends one byte before the CRC32 trailer, and never past the bytes we hold: the
+ // same D7 minimum, so a truncated frame yields a shorter string instead of no string at all.
+ let hi = min(length - 1, payloadLimit(frame, trailerStart: length))
if lo < hi && hi <= frame.count {
txt = String(decoding: Array(frame[lo.. [GroupInventory] {
let groups = Dictionary(grouping: records, by: { groupKey($0.frame) })
return groups.map { key, recs in
@@ -244,7 +256,8 @@ public enum ReTools {
let lens = recs.map { $0.bytes.count }
return GroupInventory(
key: key, count: recs.count,
- okCount: recs.filter { $0.frame.ok }.count,
+ intactCount: recs.filter { $0.frame.ok }.count,
+ parsableCount: recs.filter { $0.frame.isParsable }.count,
crcOkCount: recs.filter { $0.frame.crcOK == true }.count,
firstTsMs: ts.min(), lastTsMs: ts.max(),
minLen: lens.min() ?? 0, maxLen: lens.max() ?? 0)
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/ScanAdvertisementSummary.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/ScanAdvertisementSummary.swift
new file mode 100644
index 0000000000..a17a84965a
--- /dev/null
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/ScanAdvertisementSummary.swift
@@ -0,0 +1,82 @@
+import Foundation
+
+/// A redacted description of what a strap ADVERTISED, for the #1635 pairing-mode question.
+///
+/// The one thing no strap log can currently answer is the question the field report put back to the
+/// thread: *was the strap in pairing mode during any of those refusals?* A strap that accepts pairing
+/// almost certainly advertises differently from one that refuses, but the scan path reads only the
+/// device name and discards the rest, so the evidence is thrown away at the moment it exists.
+///
+/// It is also the ONLY diagnostic on this path that still works unbonded. The event census and the
+/// battery-pack read both ride characteristics that need an encrypted link, so on the strap we are
+/// actually trying to debug they are silent by construction. An advertisement arrives before any of it.
+///
+/// STRUCTURE, NOT PAYLOAD. The local name can carry a person's name ("'s Whoop" is what WHOOP
+/// sets by default), service data can carry a serial, and manufacturer data is opaque. So this reports
+/// what is PRESENT and how big it is and never a byte of any of it — enough to tell two advertising
+/// modes apart, which is the whole question, and carrying nothing identifying.
+///
+/// Kotlin twin: `ScanAdvertisementSummary`.
+public enum ScanAdvertisementSummary {
+
+ /// - Parameters:
+ /// - localNameLength: the local name's SIZE IN UTF-8 BYTES (see `localNameLength(_:)`), which can
+ /// differ between advertising modes and, unlike the name itself, identifies nobody.
+ public static func line(flags: Int?,
+ serviceUuids: [String],
+ serviceDataLengths: [String: Int],
+ manufacturerDataLengths: [Int: Int],
+ txPower: Int?,
+ localNameLength: Int?,
+ connectable: Bool) -> String {
+ var parts: [String] = []
+ parts.append("flags=" + (flags.map { String(format: "0x%02x", $0) } ?? "none"))
+ parts.append("connectable=\(connectable)")
+ let svc = serviceUuids.map(canonicalUuid).sorted()
+ parts.append("svc=" + (svc.isEmpty ? "none" : svc.joined(separator: ",")))
+ // Normalise BEFORE sorting: the canonical form reorders keys that the short form would not.
+ let svcData = serviceDataLengths.map { (canonicalUuid($0.key), $0.value) }.sorted { $0.0 < $1.0 }
+ parts.append("svcData=" + (svcData.isEmpty ? "none"
+ : svcData.map { "\($0.0):\($0.1)B" }.joined(separator: ",")))
+ parts.append("mfg=" + (manufacturerDataLengths.isEmpty ? "none"
+ : manufacturerDataLengths.sorted { $0.key < $1.key }
+ .map { String(format: "0x%04x:%dB", $0.key, $0.value) }.joined(separator: ",")))
+ parts.append("tx=" + (txPower.map(String.init) ?? "none"))
+ parts.append("nameLen=" + (localNameLength.map(String.init) ?? "none"))
+ return "[adv] " + parts.joined(separator: " ")
+ }
+
+ /// Expand an assigned short Bluetooth UUID to its canonical 128-bit form; pass anything else through.
+ ///
+ /// CoreBluetooth renders an assigned 16-bit UUID as "180d" and a 32-bit one as "0000180d", where
+ /// Android's `UUID.toString()` always expands to the full base UUID. Unnormalised, the same strap
+ /// advertising Heart Rate Service logs a different string on each platform, so an iOS capture cannot
+ /// be compared against an Android one — which is exactly what this line exists to allow. Normalising
+ /// inside `line` rather than at the call site means neither platform's scan path can forget to do it.
+ ///
+ /// A no-op on Android, whose input is already 128-bit; it lives in both twins so the two formatters
+ /// stay behaviourally identical rather than agreeing only by accident of their inputs.
+ static func canonicalUuid(_ s: String) -> String {
+ switch s.count {
+ case 4: return "0000\(s)-0000-1000-8000-00805f9b34fb"
+ case 8: return "\(s)-0000-1000-8000-00805f9b34fb"
+ default: return s
+ }
+ }
+
+ /// The local name's size in UTF-8 BYTES — what the advertisement actually carries.
+ ///
+ /// NOT `String.count`. Swift counts grapheme clusters and Java's `String.length` counts UTF-16 code
+ /// units, so a strap named "Whoop 🎉" reported 7 on iOS and 8 on Android — the same divergence, and
+ /// for the same reason, as the short-UUID spelling this file already canonicalises. Names carry
+ /// emoji and accents routinely (WHOOP seeds the name from the account holder), so this was not a
+ /// theoretical case.
+ ///
+ /// Bytes rather than either platform's string model because the AD local-name field IS a UTF-8 byte
+ /// run: it is the physically meaningful number, and it is identical on both platforms by
+ /// construction instead of by one imitating the other.
+ public static func localNameLength(_ name: String?) -> Int? {
+ name.map { $0.utf8.count }
+ }
+
+}
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift
index 4a8a30701d..aacfbeb8bd 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift
@@ -10,6 +10,30 @@ public struct HRSample: Equatable, Codable {
public init(ts: Int, bpm: Int) { self.ts = ts; self.bpm = bpm }
}
+/// Sensor-contact state carried by the standard BLE Heart Rate Measurement flags.
+///
+/// This is deliberately a tri-state rather than a Bool: a peripheral that does not support contact
+/// detection has not said that contact is absent. `unsupported` is also the only valid result whenever
+/// flag bit 2 is clear, irrespective of the value in detected bit 1.
+public enum StandardHRContact: String, Equatable, Codable, Sendable {
+ case unsupported
+ case supportedNotDetected = "supported_not_detected"
+ case supportedDetected = "supported_detected"
+
+ /// Decode the BLE Heart Rate Measurement flags: bit 2 = supported, bit 1 = detected.
+ /// `unsupported` wins whenever bit 2 is clear, irrespective of detected bit 1.
+ /// Twin of Kotlin `StandardHrContact.fromMeasurementFlags`.
+ public static func fromMeasurementFlags(_ flags: UInt8) -> StandardHRContact {
+ if flags & 0x04 == 0 {
+ return .unsupported
+ } else if flags & 0x02 == 0 {
+ return .supportedNotDetected
+ } else {
+ return .supportedDetected
+ }
+ }
+}
+
/// WHICH sensor channel produced an R-R interval (#1071).
///
/// A WHOOP strap has ONE beat source, so its rows carry no channel (nil) and nothing here changes for
@@ -57,8 +81,30 @@ public struct RRInterval: Equatable, Codable {
/// that second, so the over-count is accumulation across offloads. WHOOP's wire format has no
/// channel field, so `srcChannel` can never answer this for a strap — `ord` is what is left.
public let ord: Int?
- public init(ts: Int, rrMs: Int, srcChannel: RRSourceChannel? = nil, ord: Int? = nil) {
- self.ts = ts; self.rrMs = rrMs; self.srcChannel = srcChannel; self.ord = ord
+ /// Storage identity for equal beats in the same second. Defaults to zero so wire decoders and
+ /// callers that predate the widened database key remain source- and behaviour-compatible.
+ public let seq: Int
+ public init(ts: Int, rrMs: Int, srcChannel: RRSourceChannel? = nil, ord: Int? = nil, seq: Int = 0) {
+ self.ts = ts; self.rrMs = rrMs; self.srcChannel = srcChannel; self.ord = ord; self.seq = seq
+ }
+
+ private enum CodingKeys: String, CodingKey { case ts, rrMs, srcChannel, ord, seq }
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ ts = try c.decode(Int.self, forKey: .ts)
+ rrMs = try c.decode(Int.self, forKey: .rrMs)
+ srcChannel = try c.decodeIfPresent(RRSourceChannel.self, forKey: .srcChannel)
+ ord = try c.decodeIfPresent(Int.self, forKey: .ord)
+ seq = try c.decodeIfPresent(Int.self, forKey: .seq) ?? 0
+ }
+ public func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encode(ts, forKey: .ts)
+ try c.encode(rrMs, forKey: .rrMs)
+ try c.encodeIfPresent(srcChannel, forKey: .srcChannel)
+ try c.encodeIfPresent(ord, forKey: .ord)
+ // Preserve the legacy encoded shape for the overwhelmingly common seq=0 row.
+ if seq != 0 { try c.encode(seq, forKey: .seq) }
}
}
@@ -754,11 +800,16 @@ private func toWall(_ deviceTs: Int?, _ deviceClockRef: Int, _ wallClockRef: Int
///
/// HR/R-R are taken ONLY from REALTIME_DATA (type 40). REALTIME_RAW_DATA (type 43) also
/// carries an HR byte but streams alongside type-40 during raw collection, so routing both
-/// would double-count HR for the same instants. CRC-failed and non-ok frames are skipped.
+/// would double-count HR for the same instants. Frames whose integrity verdict is negative are
+/// skipped — no row is derived from a frame that is not intact.
public func extractStreams(_ parsed: [ParsedFrame],
deviceClockRef: Int, wallClockRef: Int) -> Streams {
var out = Streams()
for r in parsed {
+ // `ok` is now the FULL verdict — header checksum, payload CRC32 and structural length — so it
+ // alone rejects everything the two-part check used to. The `crcOK` half is kept for the same
+ // reason as in `extractHistoricalStreams`: a parse result decoded from a capture written before
+ // the verdict widened carries the old constant `ok: true` beside a false `crcOK`.
if !r.ok || r.crcOK == false { continue }
let p = r.parsed
switch r.typeName {
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift
new file mode 100644
index 0000000000..50d38d5d7e
--- /dev/null
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift
@@ -0,0 +1,81 @@
+import Foundation
+
+/// The WHOOP 4.0 strap serial, read out of the `GET_HELLO_HARVARD` (cmd 35) response (#1193).
+///
+/// A 4.0 does not expose the DIS Serial Number String (`0x2A25`) that gives the 5/MG its stable id, so
+/// until now the only 4.0 identity available was the CoreBluetooth peripheral UUID — which iOS re-mints
+/// whenever the strap is re-paired or re-keyed. That is the whole of #1193: one physical strap forks
+/// into a second registry row and its history is orphaned beside it.
+///
+/// The capture aid added for that hunt found the answer: in a 131-byte 4.0 cmd-35 response the serial is
+/// the 9-character alphanumeric run at offset 14.
+///
+/// ## What this deliberately does NOT read
+///
+/// The same response carries a 54-character alphanumeric run at offset 24 which is the strap's DEVICE
+/// KEY. This decoder reads a fixed 9-byte window and cannot reach it: there is no scanning, no
+/// "longest alnum run", nothing that could drift onto the key as payloads vary. A key must never become
+/// an id, reach a log, or leave the device, and the cheapest way to guarantee that is to never read it.
+///
+/// ## Why the strict shape check
+///
+/// The offset comes from ONE capture. If a future firmware moves the field, a fixed offset lands on
+/// whatever is there instead — so a window that is not entirely `[0-9A-Za-z]` is refused rather than
+/// returned. `WhoopSerialIdentity.adoptedId` then applies its own validation on top. Refusing costs a
+/// strap the stable id it would have gained; returning something wrong migrates its whole history onto
+/// a junk key, which is the failure this exists to prevent.
+public enum Whoop4HelloSerial {
+
+ /// Offset of the serial run inside the cmd-35 response payload.
+ public static let serialOffset = 14
+ /// Length of the serial run. The 54-char device key begins at offset 24, one byte past this window.
+ public static let serialLength = 9
+
+ /// The strap serial, or nil when the payload is too short or the window is not a clean alnum run.
+ public static func decode(payload: [UInt8]) -> String? {
+ guard payload.count >= serialOffset + serialLength else { return nil }
+ var out = ""
+ out.reserveCapacity(serialLength)
+ for i in serialOffset..<(serialOffset + serialLength) {
+ let b = payload[i]
+ let isDigit = b >= 0x30 && b <= 0x39
+ let isUpper = b >= 0x41 && b <= 0x5A
+ let isLower = b >= 0x61 && b <= 0x7A
+ guard isDigit || isUpper || isLower else { return nil }
+ out.append(Character(UnicodeScalar(b)))
+ }
+ return out
+ }
+}
+
+/// Withholds a serial until the SAME value has been seen twice (#1193).
+///
+/// The 5/MG adopts its DIS serial on first read, because `0x2A25` is a spec-defined field that means one
+/// thing. The 4.0 offset is not that: it came off a single capture, so "the 9-char alnum run at offset 14"
+/// is a strong inference rather than a documented field. If that run were per-session rather than
+/// per-strap, adopting immediately would mint a new id on every connect and migrate the history each time
+/// - strictly worse than the duplicate row this exists to prevent.
+///
+/// Two hellos carrying the same run cannot both be a fresh per-session token, which is the only failure
+/// mode that does real damage. The cost is that a 4.0 adopts one connect later than a 5/MG.
+///
+/// Lives here, apart from the BLE classes that use it, because it is the part most likely to be wrong and
+/// the part neither platform can unit-test in place.
+public struct RepeatedSerialGate {
+ private var candidate: String?
+ private var confirmed: String?
+
+ public init() {}
+
+ /// The serial to adopt, or nil while it is still unconfirmed. Returns non-nil only on the transition
+ /// to confirmed, so a caller may act on it directly without re-adopting on every later sighting.
+ public mutating func offer(_ serial: String) -> String? {
+ if confirmed == serial { return nil } // already acted on
+ guard candidate == serial else {
+ candidate = serial // first sighting: remember, withhold
+ return nil
+ }
+ confirmed = serial
+ return serial
+ }
+}
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift
index 18f7038c37..68fb8d068b 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift
@@ -285,16 +285,31 @@ public enum Whoop5Ecg {
}
/// The `mainControlECGDataGeneration` argument.
+ ///
+ /// ⚠️ These raw values are ATTESTED ON ONE DEVICE, and they are NOT the vendor client's declaration
+ /// order. The previous `stop = 0 / start = 1 / restart = 2` was read off that order and shipped
+ /// unverified (#896). On a WHOOP MG (`WS50_r00`, fw `50.39.1.0`) each argument was sent on its own
+ /// while watching the type-43 stream rather than the ack:
+ ///
+ /// - `0` is REFUSED — the strap answers `FAILURE(0)` and generation is unchanged, so there is no
+ /// case for it here and nothing can send it.
+ /// - `1` STOPS generation.
+ /// - `2` STARTS it. The type-43 stream only follows once `TOGGLE_LABRADOR_FILTERED (139)` is on, so
+ /// the working turn-on order is `139 = 1` then `124 = 2`. 139 gates the STREAM rather than the
+ /// front end: with 139 closed, `124 = 2` still made the strap's own console log
+ /// `MAX86176: Set ECG ON` while no packets arrived (8 sends, 8 console lines, #891).
+ ///
+ /// One device, one firmware. #1100 ran `WS50_r03` / `50.40.1.0` and nothing here says the two agree.
+ /// Whether `2` is a plain start or a stop-then-start is NOT distinguishable from a stream that was
+ /// already off, so the case is named for what it achieves rather than for the client's third name.
public enum ControlSignal: UInt8, Equatable, Sendable, CaseIterable {
- case stop = 0
- case start = 1
- case restart = 2
+ case stop = 1
+ case start = 2
public var token: String {
switch self {
case .stop: return "stop"
case .start: return "start"
- case .restart: return "restart"
}
}
}
@@ -367,7 +382,9 @@ public enum Whoop5Ecg {
case toggleRealtimeFilteredEcgCmd:
return arg != 0
case mainControlEcgDataGenerationCmd:
- return arg == ControlSignal.start.rawValue || arg == ControlSignal.restart.rawValue
+ // Only the START value. `ControlSignal.stop` (1) halts generation, so a run whose last act
+ // was that one asked for nothing and its silence is the expected outcome, not a finding.
+ return arg == ControlSignal.start.rawValue
default:
return false
}
@@ -508,6 +525,84 @@ public enum Whoop5Ecg {
return end <= payload.count && payload.count - end <= maxPadding
}
+ // MARK: - The type-43 REALTIME_RAW_DATA record: the live ECG sample carrier (#891/#1100)
+ //
+ // OBSERVED on one WHOOP MG (WS50_r00, fw 50.39.1.0), not attested by any vendor document: once
+ // TOGGLE_LABRADOR_FILTERED(139)=1 has opened the master gate, the strap emits fixed-size 240-byte
+ // REALTIME_RAW_DATA (type 43) records whose body carries an i16-LE series.
+ //
+ // Twin of the Kotlin `Whoop5Ecg` helpers of the same names. Android grew the ECG app layer that consumes
+ // these first; the decode is a PROTOCOL fact, so it lands on both platforms together rather than living
+ // only where it happens to be called today. Nothing here decodes a status field, an HR, or a rhythm
+ // classification — only the sample series and a byte-fill count. Callers gate on the frame's CRC first.
+
+ /// Every REALTIME_RAW_DATA record OBSERVED on the MG was exactly this long.
+ public static let rawRecordLength = 240
+
+ /// Frame offset of the inner record's type byte on 5/MG (`[8]type [9]seq [10]cmd`).
+ public static let rawTypeOffset = 8
+
+ /// The inner record type byte for REALTIME_RAW_DATA.
+ public static let rawRecordType: UInt8 = 43
+
+ /// First body byte considered by `realtimeRawBodyNonZeroBytes` — excludes the constant sub-header.
+ public static let rawBodyStart = 24
+
+ /// First waveform byte. Bytes 24..33 are a constant 5x i16 sub-header that is NOT waveform.
+ public static let rawWaveformStart = 34
+
+ /// One past the last body byte; the remaining 4 bytes are the frame's CRC32 trailer.
+ public static let rawBodyEnd = 236
+
+ /// Samples one record carries: 101. Load-bearing beyond arithmetic — a fixed sample count per record is
+ /// exactly the shape that makes autocorrelation manufacture a peak at the record period, so anything
+ /// estimating a rate from these samples must exclude this lag. See the #194 withdrawal for what
+ /// happens when that is not done.
+ public static let samplesPerRawRecord = (rawBodyEnd - rawWaveformStart) / 2
+
+ /// Non-zero body bytes above which a record is treated as carrying a waveform rather than baseline.
+ public static let rawBodyActiveNonZeroBytes = 20
+
+ /// True for a frame shaped like a REALTIME_RAW_DATA record. Shape only — says nothing about CRC.
+ public static func isRealtimeRawRecord(_ frame: [UInt8]) -> Bool {
+ frame.count == rawRecordLength && frame[rawTypeOffset] == rawRecordType
+ }
+
+ /// The record's i16-LE sample series, or nil when `frame` is not a REALTIME_RAW_DATA record.
+ ///
+ /// Signed two's-complement, little-endian, exactly `samplesPerRawRecord` values. Zero samples are
+ /// returned as zeros and never trimmed: for a research artifact a trailing run of zeros is evidence
+ /// about the record, not padding to be tidied away.
+ public static func realtimeRawSamples(_ frame: [UInt8]) -> [Int]? {
+ guard isRealtimeRawRecord(frame) else { return nil }
+ var out = [Int]()
+ out.reserveCapacity(samplesPerRawRecord)
+ var i = rawWaveformStart
+ while i + 1 < rawBodyEnd {
+ let raw = Int(frame[i]) | (Int(frame[i + 1]) << 8)
+ out.append(raw >= 0x8000 ? raw - 0x10000 : raw)
+ i += 2
+ }
+ return out
+ }
+
+ /// Non-zero bytes in the body region, or nil when `frame` is not a REALTIME_RAW_DATA record.
+ public static func realtimeRawBodyNonZeroBytes(_ frame: [UInt8]) -> Int? {
+ guard isRealtimeRawRecord(frame) else { return nil }
+ var n = 0
+ for i in rawBodyStart.. Bool? {
+ guard let n = realtimeRawBodyNonZeroBytes(frame) else { return nil }
+ return n > rawBodyActiveNonZeroBytes
+ }
+
/// The frame-level form of `plausibleFilteredPayload`, CRC-gated. This is what the app layer runs
/// over unclassified 5/MG frames while an ECG probe is armed.
public static func plausibleFilteredFrame(_ frame: [UInt8],
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift
index bd2230957e..b419231782 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift
@@ -8,9 +8,9 @@ import Foundation
// app captures (documented in Asherlc/dofek `docs/whoop-ble-protocol.md` as the type-0x2B raw packet)
// and the same columnar convention as the 4.0's 1917 REALTIME_RAW_DATA variant.
//
-// IMPORTANT: the 5/MG's *live* raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE / cmd 106 acks but
-// never streams), but the OFFLOAD buffer carries full accel AND gyro — so 100 Hz 6-axis IMU IS
-// obtainable on the 5.0 via the historical path.
+// Hardware validation on a WHOOP 5/MG established that live raw IMU requires START_RAW_DATA (81)
+// followed by TOGGLE_IMU_MODE (106) with payload [1,1]. Opcode 106 on its own acknowledges without
+// starting the producer. The same 1244-byte shape is also banked and later returned by history sync.
//
// Frame layout (the reassembled BLE frame = 8-byte puffin envelope + payload; offsets are FRAME-absolute):
// @15 u32 LE strap unix seconds for this 1-second frame
diff --git a/Packages/WhoopProtocol/Sources/whoop-decode/main.swift b/Packages/WhoopProtocol/Sources/whoop-decode/main.swift
index 6a5910717d..a28c79f2b2 100644
--- a/Packages/WhoopProtocol/Sources/whoop-decode/main.swift
+++ b/Packages/WhoopProtocol/Sources/whoop-decode/main.swift
@@ -20,8 +20,12 @@ import WhoopProtocol
//
// Options:
// --family F whoop4 | whoop5 | auto (default: auto — per-frame from `char`, else whoop5)
-// --json emit decoded frames as JSON (ParsedFrame + provenance) instead of a text dump
-// --raw-only print only frames that did NOT fully decode (ok=false) — the RE worklist
+// --json emit decoded frames as JSON (ParsedFrame + provenance) instead of a text dump.
+// Each frame carries `ok` (the FULL integrity verdict) and `rejectReason` (why not);
+// a downstream filter that wants only trustworthy frames filters on `ok`.
+// --raw-only print only frames that are NOT intact (ok=false) — the RE worklist. Since the
+// verdict widened, that includes frames which decoded fine but whose envelope did
+// not verify; they are printed WITH their packet type and rejection reason.
// -h, --help show this help
// MARK: - Input model
@@ -57,6 +61,11 @@ Reads a capture/fixture JSON array of {"hex": …} (the capture tool's richer
{"hex","char","hr","ts_ms"} records are read too) from FILE or stdin, or raw
frames from --hex. Family defaults to auto: derived per-frame from `char`
(fd4b…→whoop5, 6108…→whoop4), falling back to whoop5.
+
+--raw-only selects the frames that are NOT INTACT (ok=false): header checksum,
+payload CRC32 or structural length disagreed. Such a frame is still printed with
+its decoded packet type and a reason=… field naming which rule it failed. In
+--json output the same two values are `ok` and `rejectReason` per frame.
"""
var familyMode: FamilyMode = .auto
@@ -179,7 +188,10 @@ struct DecodedOut: Encodable {
var decodedOut: [DecodedOut] = []
var typeCounts: [String: Int] = [:]
-var okCount = 0, total = 0
+// INTACT = the verifier's full verdict. PARSED = a packet type came out of the bytes. They are
+// different questions since the verdict widened, and the summary reports both rather than one "ok".
+var intactCount = 0, parsedCount = 0, total = 0
+var rejectCounts: [FrameRejectReason: Int] = [:]
for (n, rec) in records.enumerated() {
guard let frame = bytes(fromHex: rec.hex) else {
@@ -191,9 +203,14 @@ for (n, rec) in records.enumerated() {
// the per-field metadata the live pipeline skips.
let parsed = parseFrame(frame, family: family, collectFields: true)
total += 1
- if parsed.ok { okCount += 1 }
+ if parsed.ok { intactCount += 1 } else { rejectCounts[parsed.rejectReason, default: 0] += 1 }
+ if parsed.isParsable { parsedCount += 1 }
typeCounts[parsed.typeName, default: 0] += 1
+ // --raw-only is the RE worklist: the frames this tool could NOT fully account for. It filters on
+ // the integrity verdict, which is what "did not fully decode" now means — a frame with a broken
+ // envelope belongs on that worklist even though its type read fine, and it is printed WITH its type
+ // and its rejection reason rather than dropped.
if rawOnly && parsed.ok { continue }
if jsonOut {
@@ -202,9 +219,10 @@ for (n, rec) in records.enumerated() {
continue
}
- // Text dump.
- let crc = parsed.crcOK.map { $0 ? "ok" : "BAD" } ?? "—"
- var head = "[\(n)] \(family.rawValue) ok=\(parsed.ok) type=\(parsed.typeName) seq=\(parsed.seq.map(String.init) ?? "—") crc=\(crc)"
+ // Text dump. The frame line itself is built by the library (`frameInspectionLine`) so a test can
+ // pin the property that a rejected frame keeps its packet type and names its reason; only the
+ // capture provenance, which the library never sees, is appended here.
+ var head = frameInspectionLine(index: n, family: family, parsed: parsed)
if let c = rec.char { head += " char=\(c)" }
if let hr = rec.hr { head += " hr=\(hr)" }
print(head)
@@ -225,9 +243,17 @@ if jsonOut {
}
} else {
// Summary to stderr so it doesn't pollute a piped dump.
- var summary = "\n— \(okCount)/\(total) frames decoded ok —\n"
+ var summary = "\n— \(intactCount)/\(total) frames intact, \(parsedCount)/\(total) readable —\n"
for (t, c) in typeCounts.sorted(by: { $0.value > $1.value }) {
summary += " \(pad(String(c), 5, right: true)) \(t)\n"
}
+ // Why the rejected ones were rejected. Never silently omitted, and never overstated: a payload
+ // CRC32 that could not be computed is reported as `payloadCRCUnverifiable`, not as a mismatch.
+ if !rejectCounts.isEmpty {
+ summary += " rejected by reason:\n"
+ for r in FrameRejectReason.allCases where (rejectCounts[r] ?? 0) > 0 {
+ summary += " \(pad(String(rejectCounts[r] ?? 0), 5, right: true)) \(r.rawValue)\n"
+ }
+ }
FileHandle.standardError.write(Data(summary.utf8))
}
diff --git a/Packages/WhoopProtocol/Sources/whoop-re/main.swift b/Packages/WhoopProtocol/Sources/whoop-re/main.swift
index b4bf4b8084..17a3e7a987 100644
--- a/Packages/WhoopProtocol/Sources/whoop-re/main.swift
+++ b/Packages/WhoopProtocol/Sources/whoop-re/main.swift
@@ -173,12 +173,18 @@ case "coverage":
case "inventory":
requireFile()
- print(pad("TYPE/VERSION", 24) + pad("COUNT", 8, right: true) + pad("OK", 8, right: true)
- + pad("CRC", 8, right: true) + pad("LEN", 12, right: true) + " TS SPAN")
+ // INTACT (full verdict) and PARSABLE (a packet type was decoded) are separate columns: a capture
+ // whose frames all read fine but whose envelopes are damaged reads 0 INTACT and n PARSABLE, and
+ // collapsing that into one "OK" column would hide which of the two is the problem.
+ print(pad("TYPE/VERSION", 24) + pad("COUNT", 8, right: true) + pad("INTACT", 8, right: true)
+ + pad("PARSED", 8, right: true) + pad("CRC", 8, right: true)
+ + pad("LEN", 12, right: true) + " TS SPAN")
for g in ReTools.inventory(loadRecords(positional[0], family)) {
let lenCol = g.minLen == g.maxLen ? "\(g.minLen)" : "\(g.minLen)-\(g.maxLen)"
let span = (g.firstTsMs != nil && g.lastTsMs != nil) ? "\(g.firstTsMs!)..\(g.lastTsMs!)" : "—"
- print(pad(g.key, 24) + pad(String(g.count), 8, right: true) + pad(String(g.okCount), 8, right: true)
+ print(pad(g.key, 24) + pad(String(g.count), 8, right: true)
+ + pad(String(g.intactCount), 8, right: true)
+ + pad(String(g.parsableCount), 8, right: true)
+ pad(String(g.crcOkCount), 8, right: true) + pad(lenCol, 12, right: true) + " \(span)")
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift
index 814b67d192..b1a2e10e99 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/BatteryPackInfoTests.swift
@@ -73,4 +73,81 @@ final class BatteryPackInfoTests: XCTestCase {
XCTAssertNil(BatteryPackInfo.decodeExtended(frame: bytes(attachedHex)))
XCTAssertNil(BatteryPackInfo.decode(frame: bytes(attachedHex))?.voltageMv)
}
+
+ /// The gauge must be sanity-checked before it is shown. These offsets are an unvalidated candidate
+ /// re-derived from two captures; a wrong one does not fail, it renders a confident wrong number — the
+ /// failure this project treats as worse than a blank. A percentage outside 0...100 means the offset
+ /// moved, so the caller renders nothing.
+ func testOutOfRangeChargeIsNotDisplayable() {
+ XCTAssertFalse(BatteryPackInfo.Info(present: true, socPct: 2488.1, serial: "P", btAddr: "aa").displayable)
+ XCTAssertFalse(BatteryPackInfo.Info(present: true, socPct: -1, serial: "P", btAddr: "aa").displayable)
+ }
+
+ /// A plausible gauge on an attached pack is the one case that shows.
+ func testInRangeChargeOnAnAttachedPackIsDisplayable() {
+ XCTAssertTrue(BatteryPackInfo.Info(present: true, socPct: 73.4, serial: "P", btAddr: "aa").displayable)
+ XCTAssertTrue(BatteryPackInfo.Info(present: true, socPct: 0, serial: "P", btAddr: "aa").displayable)
+ XCTAssertTrue(BatteryPackInfo.Info(present: true, socPct: 100, serial: "P", btAddr: "aa").displayable)
+ }
+
+ /// A removed pack must clear the card, never hold the last reading.
+ func testAbsentPackIsNeverDisplayable() {
+ XCTAssertFalse(BatteryPackInfo.Info(present: false, socPct: nil, serial: nil, btAddr: nil).displayable)
+ // Even if a stale charge rides along, absence wins.
+ XCTAssertFalse(BatteryPackInfo.Info(present: false, socPct: 80, serial: nil, btAddr: nil).displayable)
+ }
+
+ /// The router branch that decodes this reply matches on the command NAME
+ /// (`cmdName.hasPrefix("GET_BATTERY_PACK_INFO(")`), which comes from the schema's CommandNumber table.
+ /// A rename or a removal there would not fail to compile — the branch would simply stop matching and
+ /// the whole feature would go quiet, with the decoder back to having no caller. Pin the string.
+ func testCommand151ResolvesToTheNameTheRouterMatchesOn() {
+ XCTAssertEqual(loadSchema().enumName("CommandNumber", 151), "GET_BATTERY_PACK_INFO(151)")
+ }
+
+ /// `displayable` is about a CHARGE percentage, and the 4.0 path carries a voltage instead — so it is
+ /// always false there, correctly. Pinned because the name is generic enough that a future 4.0 voltage
+ /// card might reach for this gate and get a permanent no.
+ func testFourPointOhVoltagePathIsNeverDisplayable() {
+ let v = BatteryPackInfo.Info(present: true, socPct: nil, serial: nil, btAddr: nil, voltageMv: 3_900)
+ XCTAssertFalse(v.displayable)
+ }
+
+ /// REAL HARDWARE vectors: the pack record as the strap actually delivers it — the payload of the
+ /// uncatalogued pushed event 109, captured from a WHOOP MG on fw 50.39.1.0 (2026-09-01). Command 151
+ /// answers FAILURE(0) on that firmware whatever you send it, so the event is the transport the feature
+ /// runs on. The Kotlin twin asserts the same values.
+ private let packEvent569 = "f5481c000100005e005301574242354150303030303030310000003902010c00"
+ private let packEvent567 = "1e451c000100005e005301574242354150303030303030310000003702010c00"
+
+ func testPushedPackEventCarriesTheSameRecordAsTheCommandReply() {
+ let info = BatteryPackInfo.decodeEventPayload(bytes(packEvent569))
+ XCTAssertEqual(info?.present, true)
+ XCTAssertEqual(info?.serial, "WBB5AP0000001")
+ XCTAssertEqual(info?.btAddr, "00005e005301")
+ XCTAssertEqual(info?.socPct ?? -1, 56.9, accuracy: 1e-9)
+ XCTAssertNil(info?.voltageMv) // the event never carries a voltage
+ }
+
+ /// The SoC field TRACKS A VARYING INPUT rather than merely looking plausible once: these two records are
+ /// minutes apart while the pack drains into the strap, and the value falls. A single reading cannot tell
+ /// tenths-of-a-percent from mAh; a falling pair in the physically correct direction, both inside 0...100
+ /// when read as tenths, is what settles the unit.
+ func testPackSocFallsAsThePackDrainsIntoTheStrap() {
+ let first = BatteryPackInfo.decodeEventPayload(bytes(packEvent569))?.socPct ?? -1
+ let later = BatteryPackInfo.decodeEventPayload(bytes(packEvent567))?.socPct ?? -1
+ XCTAssertEqual(first, 56.9, accuracy: 1e-9)
+ XCTAssertEqual(later, 56.7, accuracy: 1e-9)
+ XCTAssertLessThan(later, first, "pack SoC must fall while discharging into the strap")
+ XCTAssertLessThanOrEqual(first, 100.0)
+ XCTAssertGreaterThanOrEqual(later, 0.0)
+ }
+
+ /// The command path and the event path must agree field-for-field — they share `decodeRecord`, and a
+ /// refactor letting them drift would silently give the Devices card two different pack readings.
+ func testCommandAndEventPathsShareOneRecordDecoder() {
+ let viaCommand = BatteryPackInfo.decode(frame: bytes(attachedHex))
+ let viaRecord = BatteryPackInfo.decodeRecord(bytes(attachedHex), base: 14) // cmdOff 10 + 4
+ XCTAssertEqual(viaCommand, viaRecord)
+ }
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift
index 18a7058c38..0d97519c24 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DataRangeTests.swift
@@ -126,4 +126,104 @@ final class DataRangeTests: XCTestCase {
"pagesBehind for \(h) at cmdOff \(cmdOff) should be \(expected)")
}
}
+
+ // MARK: - #689 PENDING ack. Byte-parity twin of the Kotlin DataRangeScanTest cases.
+ //
+ // GET_DATA_RANGE answers twice and only the second carries the ring pointers. Both frames below are
+ // real, captured on a WHOOP MG (WS50_r00, fw 50.39.1.0) on 2026-08-28, and they are one request's
+ // two replies.
+
+ /// The PENDING(2) ack: 20 bytes, result byte at cmdOff+2 is 0x02, no ring pointers in it at all.
+ func testIsPendingResponse_trueForPendingAck() {
+ XCTAssertTrue(DataRange.isPendingResponse(hex("aa010c000100271124e8220402000000c391bc3d"), cmdOff: 10))
+ }
+
+ /// The SUCCESS(1) answer to that same request. Must NOT be skipped.
+ func testIsPendingResponse_falseForSuccessAnswer() {
+ let h = "aa014c00010032d124e92204010100bf0000b4be0000c1be0000b4be00000c000000000002008f00"
+ XCTAssertFalse(DataRange.isPendingResponse(hex(h), cmdOff: 10))
+ }
+
+ /// The reason this pair matters: the ack decodes to nil, so before the guard it logged as a decode
+ /// failure once per sync while the real answer decoded fine two lines later.
+ func testPendingAckIsExactlyTheFramePagesBehindCannotDecode() {
+ let pending = hex("aa010c000100271124e8220402000000c391bc3d")
+ XCTAssertNil(DataRange.pagesBehind(from: pending, cmdOff: 10))
+ XCTAssertTrue(DataRange.isPendingResponse(pending, cmdOff: 10))
+ }
+
+ /// A frame too short to hold a result byte is not a PENDING ack; the caller keeps its own handling.
+ func testIsPendingResponse_falseWhenTooShortForResultByte() {
+ XCTAssertFalse(DataRange.isPendingResponse([UInt8](repeating: 0, count: 11), cmdOff: 10))
+ }
+
+ /// A negative cmdOff is rejected rather than indexing backwards.
+ func testIsPendingResponse_falseForNegativeCmdOff() {
+ XCTAssertFalse(DataRange.isPendingResponse([UInt8](repeating: 0, count: 40), cmdOff: -1))
+ }
+
+ // MARK: - the BLE seam's gate on this reply (task 2.15)
+
+ /// GET_DATA_RANGE(34) — the opcode the seam compares at `cmdOff`, held by the app target's command
+ /// table; the predicate takes it as an argument rather than restating the protocol fact.
+ private let getDataRangeOpcode: UInt8 = 34
+
+ /// A complete WHOOP 4.0 GET_DATA_RANGE COMMAND_RESPONSE: type@4, seq@5, cmd@6, body from 7 —
+ /// `oldest` on the aligned grid `oldestUnix` scans, `newest` one word later.
+ private func dataRangeReply(oldest: Int, newest: Int) -> [UInt8] {
+ var payload = [UInt8]()
+ for v in [oldest, newest] { for k in 0..<4 { payload.append(UInt8((v >> (8 * k)) & 0xFF)) } }
+ return frameFromPayload(payload, type: 0x24, seq: 1, cmd: getDataRangeOpcode)
+ }
+
+ /// An intact reply is accepted, and it is the value the seam then applies as the reported window
+ /// and as the offload's plausibility bounds.
+ func testAnIntactDataRangeReplyIsAcceptedAndCarriesTheWindow() {
+ let oldest = 1_750_000_000, newest = 1_780_000_000
+ let f = dataRangeReply(oldest: oldest, newest: newest)
+ let p = parseFrame(f, family: .whoop4)
+ XCTAssertTrue(p.ok, "precondition: the reply is intact")
+ XCTAssertTrue(DataRange.acceptsReply(f, cmdOff: 6, opcode: getDataRangeOpcode, verdictOK: p.ok))
+ XCTAssertEqual(DataRange.newestUnix(from: f, wallNowUnix: 1_790_000_000, futureSkewSeconds: skew48h),
+ newest)
+ XCTAssertEqual(DataRange.oldestUnix(from: f), oldest)
+ }
+
+ /// Scenario "eine Antwort mit falscher Kopfprüfsumme verändert weder den gemeldeten Zeitbereich
+ /// noch die Plausibilitätsgrenzen des Abzugs" (task 2.15). The damaged reply still DECODES a
+ /// window — the verdict is the only thing that keeps it out, which is why the gate has to be a
+ /// predicate a test can hold rather than a condition buried in the seam.
+ func testABrokenDataRangeReplyMovesNeitherTheWindowNorThePlausibilityBounds() {
+ let oldest = 1_750_000_000, newest = 1_780_000_000
+ var bad = dataRangeReply(oldest: oldest, newest: newest)
+ bad[3] ^= 0xFF // CRC-8 over the length field only
+ let p = parseFrame(bad, family: .whoop4)
+ XCTAssertEqual(p.rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(p.crcOK, true, "precondition: the payload CRC32 still verifies")
+ XCTAssertEqual(DataRange.newestUnix(from: bad, wallNowUnix: 1_790_000_000, futureSkewSeconds: skew48h),
+ newest, "precondition: these bytes DO carry a window a seam could apply")
+
+ // The seam in miniature: the reported window and the two plausibility bounds, moved only on
+ // an accepted reply. Their previous values must survive a damaged one untouched.
+ var reportedNewest: Int? = 1_700_000_001
+ var boundsNewest: Int? = 1_700_000_001
+ var boundsOldest: Int? = 1_700_000_000
+ if DataRange.acceptsReply(bad, cmdOff: 6, opcode: getDataRangeOpcode, verdictOK: p.ok) {
+ reportedNewest = DataRange.newestUnix(from: bad, wallNowUnix: 1_790_000_000,
+ futureSkewSeconds: skew48h)
+ boundsNewest = reportedNewest
+ boundsOldest = DataRange.oldestUnix(from: bad)
+ }
+ XCTAssertEqual(reportedNewest, 1_700_000_001, "the reported range must not move")
+ XCTAssertEqual(boundsNewest, 1_700_000_001, "the offload's upper plausibility bound must not move")
+ XCTAssertEqual(boundsOldest, 1_700_000_000, "the offload's lower plausibility bound must not move")
+ }
+
+ /// The gate is per-reply, not per-connection: a frame carrying another opcode at `cmdOff` is not a
+ /// data-range reply at all, however intact it is.
+ func testAnotherOpcodeIsNotADataRangeReply() {
+ let other = frameFromPayload([0x00, 0x01, 0x02, 0x03], type: 0x24, seq: 1, cmd: 26)
+ XCTAssertTrue(parseFrame(other, family: .whoop4).ok)
+ XCTAssertFalse(DataRange.acceptsReply(other, cmdOff: 6, opcode: getDataRangeOpcode, verdictOK: true))
+ }
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift
index 0151818b65..dde098a781 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift
@@ -505,4 +505,38 @@ final class DeviceConfigReadProbeTests: XCTestCase {
"""
XCTAssertEqual(report.render(), golden)
}
+
+ // MARK: - direct caller of the verifier (2.5): the useful path is unchanged
+
+ /// `DeviceConfigReadProbe.parse` gates on `verifyFrame` directly. The ordinary replies must still
+ /// decode under the tightened verifier, and the newly-rejected classes must come back as its own
+ /// `.crc` refusal instead of being read.
+ func testTheOrdinaryReplyStillDecodesUnderTheTightenedVerifier() {
+ let record = echoRecord("enable_r22_packets", value: 0x32, lead: [0x01])
+ let five = whoop5Response(cmd: 128, payload: payload(result: 1, record: record))
+ XCTAssertEqual(verifyFrame(five, family: .whoop5).reason, .none, "precondition: intact")
+ guard case .success(let r) = DeviceConfigReadProbe.parse(frame: five, family: .whoop5, expecting: 128) else {
+ return XCTFail("the 5/MG useful path must still decode")
+ }
+ XCTAssertEqual(r.value(for: "enable_r22_packets"), 0x32)
+
+ let four = whoop4Response(cmd: 121, payload: payload(result: 1, record: echoRecord("k", value: 0x31)))
+ XCTAssertEqual(verifyFrame(four, family: .whoop4).reason, .none)
+ guard case .success = DeviceConfigReadProbe.parse(frame: four, family: .whoop4, expecting: 121) else {
+ return XCTFail("the 4.0 useful path must still decode")
+ }
+ }
+
+ func testAReplyWithTrailingBytesIsRefusedNotRead() {
+ let frame = whoop4Response(cmd: 121, payload: payload(result: 1, record: echoRecord("k", value: 0x31))) + [0x00]
+ XCTAssertEqual(verifyFrame(frame, family: .whoop4).reason, .lengthMismatch)
+ XCTAssertEqual(DeviceConfigReadProbe.parse(frame: frame, family: .whoop4, expecting: 121), .failure(.crc))
+ }
+
+ func testAReplyWithABrokenHeaderChecksumIsRefusedNotRead() {
+ var frame = whoop4Response(cmd: 121, payload: payload(result: 1, record: echoRecord("k", value: 0x31)))
+ frame[3] ^= 0xFF
+ XCTAssertEqual(verifyFrame(frame, family: .whoop4).crc32OK, true, "the payload CRC32 still verifies")
+ XCTAssertEqual(DeviceConfigReadProbe.parse(frame: frame, family: .whoop4, expecting: 121), .failure(.crc))
+ }
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift
index 16c39bb0ae..c9cad4a67a 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FastPathParityTests.swift
@@ -133,7 +133,8 @@ final class FastPathParityTests: XCTestCase {
/// a full `parseFrame` on TWO things, or the pre-filter's `guard frameTypeName == "EVENT"` could diverge
/// from the full-parse `guard typeName == "EVENT"` — over-admitting (a wasted parse) or, dangerously,
/// DROPPING a real gesture:
- /// 1. for a VALID frame, the exact type name matches;
+ /// 1. for a frame that decodes at all, the exact type name matches (the peek says nothing about
+ /// INTEGRITY — `parseFrame.ok` is the verifier's verdict, and the two deliberately differ there);
/// 2. for ANY frame (incl. malformed/short/wrong-SOF), the EVENT *decision* matches — this is the
/// property the pre-filter actually rests on, and it must hold even where the two disagree on the
/// name (frameTypeName returns nil for a short frame; parseFrame calls it "INVALID/FRAGMENT" — both
@@ -142,7 +143,9 @@ final class FastPathParityTests: XCTestCase {
func check(_ frame: [UInt8], _ family: DeviceFamily, _ label: String) {
let peek = frameTypeName(frame, family: family)
let full = parseFrame(frame, family: family)
- if full.ok {
+ // Compared on PARSABILITY, not on the integrity verdict: `ok` now reports whether the
+ // frame is intact, and a corrupt frame still decodes a type name the peek must match.
+ if full.typeName != "INVALID/FRAGMENT" {
XCTAssertEqual(peek, full.typeName, "frameTypeName != parseFrame.typeName at \(label)")
}
// The contract the offload gesture pre-filter depends on: never drop/over-admit an EVENT.
@@ -156,9 +159,9 @@ final class FastPathParityTests: XCTestCase {
// Malformed / boundary frames the corpus doesn't carry, where a false-negative would silently drop a
// gesture on real BLE (fragments, corruption). Both peek and full parse must land on "not EVENT".
check([], .whoop4, "empty")
- check([0xAA, 0x01, 0x02], .whoop4, "short (below the 8-byte min)")
+ check([0xAA, 0x01, 0x02], .whoop4, "short (below the 11-byte min)")
check([0x00, 0x18, 0x00, 0xff, 0x28, 0x02, 0x0f, 0x00], .whoop4, "wrong SOF")
- check(hexToBytes("aa010c000001"), .whoop5, "short 5/MG (below the 12-byte min)")
+ check(hexToBytes("aa010c000001"), .whoop5, "short 5/MG (below the 13-byte min)")
}
/// The DEFAULT call is the fast path on both entry points, so every live/offload call site
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift
index 4b21687df5..137e99c186 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FeatureFlagProbeTests.swift
@@ -870,4 +870,37 @@ final class FeatureFlagProbeTests: XCTestCase {
XCTAssertTrue(v.contains("an implausible 9999 flag(s)"), v)
XCTAssertTrue(v.contains("(inconclusive)"), v)
}
+
+ // MARK: - direct caller of the verifier (2.5): the useful path is unchanged
+
+ /// `FeatureFlagProbe.record` calls `verifyFrame` itself rather than going through `parseFrame`, so a
+ /// stricter verifier could have silently broken this probe. These pin that it did not — the ordinary
+ /// well-formed replies of BOTH families still decode — and that the classes the verifier newly
+ /// rejects come back as `.crc`, its own refusal, rather than being read as data.
+ func testTheOrdinaryReplyStillDecodesUnderTheTightenedVerifier() {
+ let four = whoop4Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x0B, 0x00]))
+ XCTAssertTrue(verifyFrame(four, family: .whoop4).ok, "precondition: an ordinary 4.0 reply is intact")
+ XCTAssertEqual(verifyFrame(four, family: .whoop4).reason, .none)
+ guard case .success = FeatureFlagProbe.parseStart(frame: four, family: .whoop4) else {
+ return XCTFail("the 4.0 useful path must still decode")
+ }
+ let five = whoop5Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x10, 0x00]))
+ XCTAssertTrue(verifyFrame(five, family: .whoop5).ok)
+ guard case .success = FeatureFlagProbe.parseStart(frame: five, family: .whoop5) else {
+ return XCTFail("the 5/MG useful path must still decode")
+ }
+ }
+
+ func testAReplyWithTrailingBytesIsRefusedNotRead() {
+ let frame = whoop4Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x0B, 0x00])) + [0x00]
+ XCTAssertEqual(verifyFrame(frame, family: .whoop4).reason, .lengthMismatch)
+ XCTAssertEqual(FeatureFlagProbe.parseStart(frame: frame, family: .whoop4), .failure(.crc))
+ }
+
+ func testAReplyWithABrokenHeaderChecksumIsRefusedNotRead() {
+ var frame = whoop4Response(cmd: 117, payload: payload(result: 1, record: [0x01, 0x0B, 0x00]))
+ frame[3] ^= 0xFF
+ XCTAssertEqual(verifyFrame(frame, family: .whoop4).crc32OK, true, "the payload CRC32 still verifies")
+ XCTAssertEqual(FeatureFlagProbe.parseStart(frame: frame, family: .whoop4), .failure(.crc))
+ }
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift
new file mode 100644
index 0000000000..b16ac106a9
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift
@@ -0,0 +1,218 @@
+import XCTest
+@testable import WhoopProtocol
+
+/// Scenario group "Diagnose behält Parsbarkeit und Ablehnungsgrund", plus the two counters D3 asks for.
+///
+/// The frames are the same corpus-derived fixtures the integrity tests use, each broken in exactly ONE
+/// way, so every assertion below attributes a single cause.
+final class FrameDiagnosticsTests: XCTestCase {
+
+ private func hex(_ s: String) -> [UInt8] { FrameIntegrityTests.hex(s) }
+
+ /// A real WHOOP 4.0 REALTIME_DATA frame with ONLY its header checksum broken: the class that
+ /// passed every gate before this change.
+ private func headerBroken() -> [UInt8] {
+ var f = hex(FrameIntegrityTests.w4Valid)
+ f[3] ^= 0xFF
+ return f
+ }
+
+ // MARK: - parseability is not integrity
+
+ func testARejectedFrameKeepsItsPacketType() {
+ let p = parseFrame(headerBroken())
+ XCTAssertFalse(p.ok)
+ XCTAssertTrue(p.isParsable, "the decoder read this frame; only the envelope failed")
+ XCTAssertEqual(p.typeName, "REALTIME_DATA")
+ }
+
+ func testAByteRunThatIsNotAFrameIsNotParsable() {
+ let p = parseFrame([0xAA, 0x00, 0x00])
+ XCTAssertFalse(p.isParsable)
+ XCTAssertEqual(p.typeName, ParsedFrame.unparsableTypeName)
+ }
+
+ func testAnIntactFrameIsBothIntactAndParsable() {
+ let p = parseFrame(hex(FrameIntegrityTests.w4Valid))
+ XCTAssertTrue(p.ok)
+ XCTAssertTrue(p.isParsable)
+ XCTAssertEqual(p.rejectReason, .none)
+ }
+
+ // MARK: - the inspector's line keeps type AND names the reason
+
+ func testInspectionLineOfARejectedFrameCarriesTypeAndReason() {
+ let line = frameInspectionLine(index: 7, family: .whoop4, parsed: parseFrame(headerBroken()))
+ XCTAssertTrue(line.contains("ok=false"), line)
+ XCTAssertTrue(line.contains("type=REALTIME_DATA"), "a rejected frame must keep its type: \(line)")
+ XCTAssertTrue(line.contains("reason=headerChecksumMismatch"), line)
+ // The payload CRC32 verified — the line says so rather than smearing one failure over both.
+ XCTAssertTrue(line.contains("crc=ok"), line)
+ }
+
+ func testInspectionLineOfAnIntactFrameCarriesNoReason() {
+ let line = frameInspectionLine(index: 0, family: .whoop4,
+ parsed: parseFrame(hex(FrameIntegrityTests.w4Valid)))
+ XCTAssertTrue(line.contains("ok=true"), line)
+ XCTAssertFalse(line.contains("reason="), "an intact frame has no reason to report: \(line)")
+ }
+
+ /// Scenario "Nicht prüfbar wird nicht als falsch gemeldet": a payload CRC32 that could not be
+ /// computed is reported as unverifiable, and the CRC column stays "—" rather than claiming "BAD".
+ func testUnverifiablePayloadCRCIsNotReportedAsAMismatch() {
+ let p = parseFrame(hex(FrameIntegrityTests.w4Total8))
+ XCTAssertNil(p.crcOK)
+ XCTAssertNotEqual(p.rejectReason, .payloadCRCMismatch)
+ let line = frameInspectionLine(index: 1, family: .whoop4, parsed: p)
+ XCTAssertTrue(line.contains("crc=—"), line)
+ XCTAssertFalse(line.contains("crc=BAD"), "we did not observe a mismatch, so we do not report one")
+ }
+
+ // MARK: - the capture export keeps the type and gains the reason
+
+ func testCaptureRecordKeepsThePacketTypeOfARejectedFrame() {
+ // A 5/MG frame with only its header checksum broken.
+ var frame = hex(FrameIntegrityTests.w5Valid)
+ frame[6] ^= 0xFF
+ let capture = PuffinCapture()
+ let rec = capture.record(frame: frame, char: "fd4b0005", tsMs: 1234, hr: 60)
+ XCTAssertFalse(rec.ok)
+ XCTAssertEqual(rec.typeName, "COMMAND_RESPONSE",
+ "a capture exists to map unexplained frames; blanking the type throws that away")
+ XCTAssertEqual(rec.rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(rec.crcOK, true)
+ }
+
+ func testCaptureRecordOfAnIntactFrameCarriesNoReason() {
+ let capture = PuffinCapture()
+ let rec = capture.record(frame: hex(FrameIntegrityTests.w5Valid), char: "fd4b0005",
+ tsMs: 1, hr: nil)
+ XCTAssertTrue(rec.ok)
+ XCTAssertEqual(rec.rejectReason, .none)
+ }
+
+ func testCaptureJSONCarriesTheReasonAndOlderCapturesStillDecode() throws {
+ var frame = hex(FrameIntegrityTests.w5Valid)
+ frame[6] ^= 0xFF
+ let capture = PuffinCapture()
+ capture.record(frame: frame, char: "fd4b0005", tsMs: 1234, hr: nil)
+ let json = try XCTUnwrap(String(data: try capture.encodedJSON(), encoding: .utf8))
+ XCTAssertTrue(json.contains("\"reject_reason\" : \"headerChecksumMismatch\""), json)
+ XCTAssertTrue(json.contains("\"type_name\""), "the packet type stays in the export")
+
+ // A record written before the key existed must still decode — additive, not breaking.
+ let legacy = """
+ [{"hex":"aa","char":"fd4b0005","ts_ms":1,"type_name":"EVENT","seq":2,"crc_ok":true,"ok":true}]
+ """
+ let decoded = try JSONDecoder().decode([PuffinCaptureRecord].self, from: Data(legacy.utf8))
+ // Spelled out: a bare `.none` here would resolve to `Optional.none`, not to the reason.
+ XCTAssertEqual(decoded.first?.rejectReason, FrameRejectReason.none)
+ XCTAssertEqual(decoded.first?.typeName, "EVENT")
+ }
+
+ // MARK: - counters (D3)
+
+ func testTallyCountsEachReasonSeparately() {
+ var tally = FrameRejectTally()
+ tally.note(parseFrame(headerBroken()))
+ tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid) + [0x00])) // trailing byte
+ tally.note(parseFrame(hex(FrameIntegrityTests.w4Total8))) // below the 11-byte floor
+ tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid))) // intact: not counted
+ XCTAssertEqual(tally.count(.headerChecksumMismatch), 1)
+ XCTAssertEqual(tally.count(.lengthMismatch), 1)
+ XCTAssertEqual(tally.count(.belowMinimumLength), 1)
+ XCTAssertEqual(tally.count(.none), 0, "an intact frame is never counted as a rejection")
+ XCTAssertEqual(tally.totalRejected, 3)
+ }
+
+ func testTallyReportsUnverifiableSeparatelyFromMismatch() {
+ var tally = FrameRejectTally()
+ // Payload byte flipped: the CRC32 was computed and disagreed.
+ var wrongPayload = hex(FrameIntegrityTests.w4Valid)
+ wrongPayload[10] ^= 0xFF
+ tally.note(parseFrame(wrongPayload))
+ XCTAssertEqual(tally.count(.payloadCRCMismatch), 1)
+ XCTAssertEqual(tally.count(.payloadCRCUnverifiable), 0,
+ "\"could not be checked\" and \"was wrong\" are different claims")
+ }
+
+ /// Scenario "Die vorher durchgelassene Klasse ist einzeln ablesbar" (2.16) — the ONE counter the
+ /// hardware run's abort criterion is read from. A per-reason bucket cannot express it: the length
+ /// bucket also collects the harmless resyncs after a lost notification.
+ func testThePreviouslyAdmittedClassHasItsOwnCounter() {
+ var tally = FrameRejectTally()
+ tally.note(parseFrame(headerBroken())) // header wrong, CRC32 right
+ tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid) + [0x00])) // length wrong, CRC32 right
+ var wrongPayload = hex(FrameIntegrityTests.w4Valid)
+ wrongPayload[10] ^= 0xFF
+ tally.note(parseFrame(wrongPayload)) // CRC32 wrong: NOT the class
+ tally.note(parseFrame(hex(FrameIntegrityTests.w4Total8))) // CRC32 unverifiable: not it
+ XCTAssertEqual(tally.payloadCRCOKButEnvelopeRejected, 2)
+ XCTAssertEqual(tally.totalRejected, 4, "the class is counted BESIDE its reason, not instead of it")
+ }
+
+ /// The offload path never parses its frames at the BLE seam — it hands them straight to the
+ /// Backfiller — so the parse-result overload cannot count them, and the ONE counter the hardware
+ /// run's abort criterion reads was blind for exactly the traffic in which the loss occurs. Counting
+ /// from the verifier's own result closes that without a second parse.
+ func testTheVerdictOverloadCountsTheSameReasonAndClassAsTheParseResult() {
+ let broken = headerBroken()
+ var fromParse = FrameRejectTally()
+ fromParse.note(parseFrame(broken, family: .whoop4))
+ var fromVerdict = FrameRejectTally()
+ let reason = fromVerdict.note(verifyFrame(broken, family: .whoop4))
+ XCTAssertEqual(reason, .headerChecksumMismatch)
+ XCTAssertEqual(fromVerdict, fromParse, "the two routes into the tally must agree exactly")
+ XCTAssertEqual(fromVerdict.payloadCRCOKButEnvelopeRejected, 1,
+ "envelope rejected while the payload CRC32 verified — the abort-criterion class")
+ }
+
+ /// The same three separations the parse-result overload keeps: an intact frame is not counted, a
+ /// wrong payload CRC32 is not the named class, and an unverifiable one is neither.
+ func testTheVerdictOverloadKeepsTheClassSeparations() {
+ var tally = FrameRejectTally()
+ XCTAssertEqual(tally.note(verifyFrame(hex(FrameIntegrityTests.w4Valid), family: .whoop4)), .none)
+ XCTAssertEqual(tally.totalRejected, 0, "an intact frame is never counted as a rejection")
+ var wrongPayload = hex(FrameIntegrityTests.w4Valid)
+ wrongPayload[10] ^= 0xFF
+ tally.note(verifyFrame(wrongPayload, family: .whoop4))
+ tally.note(verifyFrame(hex(FrameIntegrityTests.w4Total8), family: .whoop4))
+ XCTAssertEqual(tally.count(.payloadCRCMismatch), 1)
+ XCTAssertEqual(tally.count(.belowMinimumLength), 1)
+ XCTAssertEqual(tally.payloadCRCOKButEnvelopeRejected, 0,
+ "neither a wrong nor an uncomputable payload CRC32 is the admitted class")
+ }
+
+ /// Scenario "Vom Zusammensetzer verworfene Byteläufe sind gesondert erfasst": a byte run the
+ /// reassembler drops never reaches a parser — and never reaches the evidence-preserving reader —
+ /// so its disappearance is only visible if it is counted.
+ func testReassemblerDropsAreCountedAsBelowMinimumLength() {
+ let r = Reassembler(family: .whoop4)
+ // A start-of-frame declaring a total of 8 bytes (below the 11-byte floor), then a real frame.
+ let runt: [UInt8] = [0xAA, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+ let good = hex(FrameIntegrityTests.w4Valid)
+ let out = r.feed(runt + good)
+ XCTAssertEqual(out.count, 1, "the stream resyncs and still yields the real frame")
+ XCTAssertGreaterThan(r.belowMinimumLengthDrops, 0, "precondition: the runt was dropped")
+
+ var tally = FrameRejectTally()
+ tally.absorbReassemblerDrops(r.belowMinimumLengthDrops)
+ XCTAssertEqual(tally.count(.belowMinimumLength), r.belowMinimumLengthDrops)
+ // The counter is monotonic, so folding it again must not double-count.
+ tally.absorbReassemblerDrops(r.belowMinimumLengthDrops)
+ XCTAssertEqual(tally.count(.belowMinimumLength), r.belowMinimumLengthDrops)
+ }
+
+ func testSummaryLineIsSilentWhenNothingWasRejected() {
+ var tally = FrameRejectTally()
+ tally.note(parseFrame(hex(FrameIntegrityTests.w4Valid)))
+ XCTAssertNil(tally.summaryLine(), "no \"0 rejections\" line to read past")
+ }
+
+ func testSummaryLineNamesOnlyTheReasonsThatOccurred() {
+ var tally = FrameRejectTally()
+ tally.note(parseFrame(headerBroken()))
+ let line = try? XCTUnwrap(tally.summaryLine())
+ XCTAssertEqual(line, "frameReject total=1 headerChecksumMismatch=1 payloadCRCOKButEnvelopeRejected=1")
+ }
+}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift
new file mode 100644
index 0000000000..0c918dfb23
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift
@@ -0,0 +1,203 @@
+import XCTest
+@testable import WhoopProtocol
+
+/// SHARED FRAME-INTEGRITY ORACLE — the Swift half of a Swift <-> Kotlin drift guard for the
+/// `frame-integrity` capability (requirement "Plattformgleichheit des Integritätsurteils", D5).
+///
+/// `Resources/frame_integrity_oracle.json` holds one line per frame with the three values the two
+/// platforms must agree on: the verifier's FULL verdict, the rejection reason, and the historical
+/// metadata classification. The IDENTICAL file is committed at
+/// `android/app/src/test/resources/frame_integrity_oracle.json`, where `FrameIntegrityOracleTest.kt`
+/// asserts the same lines through the Kotlin `Framing.parseFrame` / `classifyHistoricalMeta`.
+///
+/// Why a shared file rather than two per-platform suites: both platforms already had their own
+/// integrity tests, and both were green while the Kotlin ECG payload path still applied the older,
+/// looser envelope bounds. Each suite asserted its own answer, so nothing compared the two answers.
+/// This does, over every case and every field.
+///
+/// The file is GENERATED by a standalone Swift run
+/// (`openspec/changes/frame-integrity/oracle-gen`) — never hand-written, so no expected value here is
+/// somebody's belief about what the verifier does. Regenerate it rather than editing it.
+///
+/// Deliberately NOT pinned: the export spelling of a reason. The Kotlin capture writer omits the
+/// `reject_reason` key when there is no reason, Swift's `Codable` always writes it. That is a textual
+/// difference in a different artefact; the oracle pins the judgement, not the serialisation.
+///
+/// Deliberately absent: a `payloadCRCUnverifiable` line. The structural rules run first on BOTH
+/// platforms, so no byte run can reach that branch — it is defence in depth, and inventing a case for
+/// it would mean inventing an input that cannot exist.
+final class FrameIntegrityOracleTests: XCTestCase {
+
+ private struct Oracle: Decodable {
+ let coverage: Coverage
+ let cases: [OracleCase]
+ }
+
+ private struct Coverage: Decodable {
+ let caseCount: Int
+ let classes: [String: Int]
+ let reasons: [String: Int]
+ private enum CodingKeys: String, CodingKey {
+ case caseCount = "case_count"
+ case classes, reasons
+ }
+ }
+
+ private struct OracleCase: Decodable {
+ let name: String
+ let family: String
+ let klass: String
+ let hex: String
+ let verdict: Bool
+ let rejectReason: String
+ let meta: String
+ let ecgPath: Bool
+ let ecgInnerPayloadHex: String?
+ private enum CodingKeys: String, CodingKey {
+ case name, family, hex, verdict, meta
+ case klass = "class"
+ case rejectReason = "reject_reason"
+ case ecgPath = "ecg_path"
+ case ecgInnerPayloadHex = "ecg_inner_payload_hex"
+ }
+ }
+
+ private static let oracleResource = "frame_integrity_oracle"
+ private static let androidCopy = "android/app/src/test/resources/frame_integrity_oracle.json"
+
+ private func loadOracle() throws -> Oracle {
+ let url = try XCTUnwrap(Bundle.module.url(forResource: Self.oracleResource, withExtension: "json"),
+ "frame_integrity_oracle.json missing from the test bundle")
+ return try JSONDecoder().decode(Oracle.self, from: Data(contentsOf: url))
+ }
+
+ private func bytes(_ hex: String) -> [UInt8] {
+ var out: [UInt8] = []
+ var i = hex.startIndex
+ while i < hex.endIndex {
+ let j = hex.index(i, offsetBy: 2)
+ out.append(UInt8(hex[i.. String {
+ b.map { String(format: "%02x", $0) }.joined()
+ }
+
+ private func family(_ name: String) throws -> DeviceFamily {
+ switch name {
+ case "whoop4": return .whoop4
+ case "whoop5": return .whoop5
+ default:
+ XCTFail("unknown family \(name) in the oracle")
+ throw XCTSkip("unknown family")
+ }
+ }
+
+ /// The classification rendered exactly as the generator renders it, so one string compares three
+ /// possible shapes (case, and for an end its unix + trim) without a second decoding rule.
+ private func metaLabel(_ p: ParsedFrame) -> String {
+ switch classifyHistoricalMeta(p) {
+ case .start: return "start"
+ case .complete: return "complete"
+ case .other: return "other"
+ case .end(let unix, let trim): return "end(unix=\(unix),trim=\(trim))"
+ }
+ }
+
+ // MARK: - the three pinned fields, every case
+
+ func testEveryOracleCaseMatchesTheSharedExpectation() throws {
+ let oracle = try loadOracle()
+ XCTAssertFalse(oracle.cases.isEmpty, "an empty oracle would assert nothing")
+ for c in oracle.cases {
+ let frame = bytes(c.hex)
+ let parsed = parseFrame(frame, family: try family(c.family))
+ XCTAssertEqual(parsed.ok, c.verdict, "verdict for \(c.name)")
+ XCTAssertEqual(parsed.rejectReason.rawValue, c.rejectReason, "reject reason for \(c.name)")
+ XCTAssertEqual(metaLabel(parsed), c.meta, "historical-metadata classification for \(c.name)")
+ // The reason and the verdict are one statement, not two that could drift apart.
+ XCTAssertEqual(parsed.ok, parsed.rejectReason == .none,
+ "\(c.name): ok must hold exactly when the reason is none")
+ }
+ }
+
+ /// Scenario "Der zweite verifizierende Pfad ist mit abgedeckt": the ECG payload accessor is the
+ /// other place a frame envelope is judged, so the oracle carries its answer too.
+ func testEveryEcgPathCaseMatchesTheSharedExpectation() throws {
+ let oracle = try loadOracle()
+ let ecgCases = oracle.cases.filter(\.ecgPath)
+ XCTAssertFalse(ecgCases.isEmpty, "the second verifying path must stay covered")
+ for c in ecgCases {
+ let payload = Whoop5Ecg.innerPayload(bytes(c.hex))
+ XCTAssertEqual(payload.map(hexString), c.ecgInnerPayloadHex,
+ "ECG inner payload for \(c.name)")
+ }
+ }
+
+ // MARK: - self-defence: the oracle cannot silently stop covering something
+
+ func testOracleCoverageManifestMatchesTheCases() throws {
+ let oracle = try loadOracle()
+ XCTAssertEqual(oracle.cases.count, oracle.coverage.caseCount, "case_count")
+ XCTAssertEqual(Set(oracle.cases.map(\.name)).count, oracle.cases.count, "case names must be unique")
+
+ var classes: [String: Int] = [:]
+ var reasons: [String: Int] = [:]
+ for c in oracle.cases {
+ classes[c.klass, default: 0] += 1
+ reasons[c.rejectReason, default: 0] += 1
+ }
+ XCTAssertEqual(classes, oracle.coverage.classes, "per-class counts")
+ XCTAssertEqual(reasons, oracle.coverage.reasons, "per-reason counts")
+
+ // The input classes task 4.1 names. Losing one would leave the oracle green and blind.
+ for required in ["valid_recorded", "header_corrupt", "payload_corrupt", "below_minimum",
+ "truncated", "trailing_bytes", "boundary", "no_start_of_frame",
+ "evaluation_order", "ecg_path"] {
+ XCTAssertGreaterThan(classes[required] ?? 0, 0, "input class \(required) is not covered")
+ }
+ // Both families, on both sides of the verdict.
+ for fam in ["whoop4", "whoop5"] {
+ XCTAssertTrue(oracle.cases.contains { $0.family == fam && $0.verdict },
+ "no accepted \(fam) frame in the oracle")
+ XCTAssertTrue(oracle.cases.contains { $0.family == fam && !$0.verdict },
+ "no rejected \(fam) frame in the oracle")
+ }
+ // Every reachable reason. `payloadCRCUnverifiable` is unreachable on both platforms and is
+ // therefore absent by design — asserting it here would demand an impossible input.
+ for reason in FrameRejectReason.allCases where reason != .payloadCRCUnverifiable {
+ XCTAssertGreaterThan(reasons[reason.rawValue] ?? 0, 0,
+ "reason \(reason.rawValue) is not covered by any oracle case")
+ }
+ XCTAssertNil(reasons["payloadCRCUnverifiable"],
+ "payloadCRCUnverifiable cannot be produced by any input — a line claiming it is wrong")
+ // Each historical-metadata outcome, since that is the third pinned field.
+ let metas = Set(oracle.cases.map(\.meta))
+ XCTAssertTrue(metas.contains("start"))
+ XCTAssertTrue(metas.contains("complete"))
+ XCTAssertTrue(metas.contains("other"))
+ XCTAssertTrue(metas.contains { $0.hasPrefix("end(") })
+ }
+
+ func testOracleCopiesAreIdentical() throws {
+ let swiftURL = try XCTUnwrap(Bundle.module.url(forResource: Self.oracleResource, withExtension: "json"))
+ let swiftData = try Data(contentsOf: swiftURL)
+
+ // .../Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift -> repo root
+ let repoRoot = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent() // WhoopProtocolTests
+ .deletingLastPathComponent() // Tests
+ .deletingLastPathComponent() // WhoopProtocol
+ .deletingLastPathComponent() // Packages
+ .deletingLastPathComponent() // repo root
+ let androidURL = repoRoot.appendingPathComponent(Self.androidCopy)
+ guard FileManager.default.fileExists(atPath: androidURL.path) else {
+ throw XCTSkip("android oracle copy not present at \(androidURL.path)")
+ }
+ XCTAssertEqual(swiftData, try Data(contentsOf: androidURL),
+ "frame_integrity_oracle.json copies differ — keep the Swift and Android copies in lockstep")
+ }
+}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityTests.swift
new file mode 100644
index 0000000000..1558255f12
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityTests.swift
@@ -0,0 +1,402 @@
+import XCTest
+@testable import WhoopProtocol
+
+/// The frame-integrity contract: the parse result carries the verifier's FULL verdict plus a
+/// non-optional reason, the structural bounds are enforced per device family, and a named inner
+/// field is only ever read out of payload bytes.
+///
+/// Every synthetic frame below is built the way a strap builds one (real CRC-8 / CRC-16-Modbus
+/// header checksum, real zlib CRC32 payload trailer, exact declared length) and then broken in
+/// exactly ONE way, so each assertion attributes a single cause. The frames taken from the capture
+/// corpus are marked as such.
+final class FrameIntegrityTests: XCTestCase {
+
+ static func hex(_ s: String) -> [UInt8] { FramingTests.hex(s) }
+
+ // MARK: - Fixtures
+
+ /// Real captured WHOOP 4.0 REALTIME_DATA frame (28 bytes), fully valid.
+ static let w4Valid = "aa1800ff28020f3de10128663c0000000000000000000000da855212"
+ /// Real captured WHOOP 4.0 METADATA frames. The 11-byte ones sit EXACTLY on the family minimum
+ /// and their `meta_type` occupies the last payload byte — the case that fixes the bound's
+ /// comparison form ("start + length must not exceed the limit").
+ static let w4MetaHistoryStart11 = "aa07006b3100012366efad"
+ static let w4MetaHistoryComplete11 = "aa07006b3100030f07e143"
+ /// Real captured 25-byte HISTORY_END frame. Its CRC32 trailer starts at 21, and the 8-byte
+ /// acknowledgement block the strap echoes back runs from 17 to 25 — into the trailer.
+ static let w4HistoryEnd25 = "aa15001631000200f1536500000000000039300000c3401bdb"
+ /// Synthetic, fully valid WHOOP 5.0/MG COMMAND_RESPONSE frame (20 bytes).
+ static let w5Valid = "aa010c000001e74124070211223344557481f36e"
+
+ /// Total 8, 9 and 10 bytes: declared lengths 4, 5 and 6. Each carries a correct header checksum
+ /// and a correct CRC32 over its (0-2 byte) inner record, so ONLY the structural minimum rejects
+ /// them — which is the point.
+ static let w4Total8 = "aa04005400000000"
+ static let w4Total9 = "aa05004131b7efdc83"
+ static let w4Total10 = "aa06007e31004d158487"
+ /// Total 12 bytes (declared 4): a WHOOP 5.0 frame one byte below the minimum, again with both
+ /// checksums correct. Its byte at offset 8 — the inner packet type — is the first byte of its
+ /// own CRC32 trailer.
+ static let w5Total12 = "aa0104000001e52100000000"
+
+ /// Exactly 13 bytes (declared 5): valid header checksum, valid CRC32 over its single payload
+ /// byte, exact length. It PASSES the tightened gate; the sequence number, command byte and
+ /// metadata type it appears to carry are all inside its CRC32 trailer.
+ static let w5Min13Metadata = "aa0105000001e4dd31b7efdc83"
+ /// The same frame at 14 bytes (declared 6). It is fully valid AND the first byte of its trailer
+ /// reads 3 = HISTORY_COMPLETE at the metadata-type offset — the forgery F-05 describes. The
+ /// sequence number at offset 9 is now a genuine payload byte and must still decode.
+ static let w5Meta14FakesHistoryComplete = "aa0106000001e49931530315e675"
+
+ /// WHOOP 4.0 REALTIME_DATA with a SHORTENED inner record: 14 bytes, declared length 10, so the
+ /// trailer starts at 10 and everything the schema places at 10 or beyond is trailer, not data.
+ static let w4ShortRealtime14 = "aa0a0082280111223344854deb31"
+ /// 11 bytes present, declared length 61440. Structurally the frame claims ~60 KB it does not
+ /// have; the bound must fall back on the bytes that exist.
+ static let w4HugeDeclared11 = "aa00f0de31000102030405"
+
+ private func corrupt(_ hexString: String, at index: Int) -> [UInt8] {
+ var f = Self.hex(hexString)
+ f[index] ^= 0xFF
+ return f
+ }
+
+ // MARK: - Vollständiges Integritätsurteil im Parse-Ergebnis
+
+ func testWhoop4HeaderChecksumWrongPayloadCRCRightIsRejected() {
+ let frame = corrupt(Self.w4Valid, at: 3)
+ let parsed = parseFrame(frame)
+ XCTAssertFalse(parsed.ok, "a broken header checksum must not yield a positive verdict")
+ XCTAssertEqual(parsed.rejectReason, .headerChecksumMismatch)
+ // The payload CRC32 is untouched — this is exactly the class that passed the gates before.
+ XCTAssertEqual(parsed.crcOK, true)
+ // …and the frame stays readable for an inspector.
+ XCTAssertEqual(parsed.typeName, "REALTIME_DATA")
+ }
+
+ func testWhoop5HeaderChecksumWrongPayloadCRCRightIsRejected() {
+ let frame = corrupt(Self.w5Valid, at: 6)
+ let parsed = parseFrame(frame, family: .whoop5)
+ XCTAssertFalse(parsed.ok)
+ XCTAssertEqual(parsed.rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(parsed.crcOK, true)
+ XCTAssertEqual(parsed.typeName, "COMMAND_RESPONSE")
+ }
+
+ func testPayloadCRCThatCannotBeComputedIsNegativeNotUnknown() {
+ // Too short for a CRC32 over any payload byte at all.
+ let parsed = parseFrame(Self.hex(Self.w4Total8))
+ XCTAssertFalse(parsed.ok, "an uncomputable payload CRC must never read as a pass")
+ XCTAssertNil(parsed.crcOK, "the tri-state stays honest: the CRC is unknown …")
+ XCTAssertEqual(parsed.rejectReason, .belowMinimumLength, "… but the verdict is not")
+ }
+
+ func testFullyValidFrameKeepsItsPositiveVerdictAndFields() {
+ let parsed = parseFrame(Self.hex(Self.w4Valid))
+ XCTAssertTrue(parsed.ok)
+ XCTAssertEqual(parsed.rejectReason, .none)
+ XCTAssertEqual(parsed.crcOK, true)
+ XCTAssertEqual(parsed.typeName, "REALTIME_DATA")
+ XCTAssertEqual(parsed.seq, 2)
+ XCTAssertEqual(parsed.parsed["heart_rate"], .int(60))
+
+ let five = parseFrame(Self.hex(Self.w5Valid), family: .whoop5)
+ XCTAssertTrue(five.ok)
+ XCTAssertEqual(five.rejectReason, .none)
+ XCTAssertEqual(five.seq, 7)
+ }
+
+ // MARK: - Ablehnungsgrund liegt am Parse-Ergebnis an
+
+ func testConsumerReadsTheReasonFromTheParseResultAlone() {
+ // A consumer sees only what it was handed — no frame bytes, no second verify, no second
+ // parse. That is the whole point of carrying the reason on the result.
+ func report(_ parsed: ParsedFrame) -> FrameRejectReason { parsed.rejectReason }
+ XCTAssertEqual(report(parseFrame(corrupt(Self.w4Valid, at: 3))), .headerChecksumMismatch)
+ XCTAssertEqual(report(parseFrame(corrupt(Self.w4Valid, at: 10))), .payloadCRCMismatch)
+ }
+
+ func testEveryRejectionReasonIsDistinguishable() {
+ var wrongSOF = Self.hex(Self.w4Valid)
+ wrongSOF[0] = 0x00
+ let trailing = Self.hex(Self.w4Valid) + [0x00]
+
+ let reasons: [FrameRejectReason] = [
+ parseFrame(wrongSOF).rejectReason,
+ parseFrame(Self.hex(Self.w4Total10)).rejectReason,
+ parseFrame(trailing).rejectReason,
+ parseFrame(corrupt(Self.w4Valid, at: 3)).rejectReason,
+ parseFrame(corrupt(Self.w4Valid, at: 10)).rejectReason,
+ parseFrame(Self.hex(Self.w4Valid)).rejectReason,
+ ]
+ XCTAssertEqual(reasons, [.noStartOfFrame, .belowMinimumLength, .lengthMismatch,
+ .headerChecksumMismatch, .payloadCRCMismatch, .none])
+ XCTAssertEqual(Set(reasons).count, reasons.count, "the reasons must not collapse into each other")
+ // The seventh reason exists so an uncomputable CRC can never be reported as a wrong one. The
+ // structural rules above make it unreachable from the verifier today; it is a fail-closed
+ // default, and this pins that it is a value of its own rather than an alias.
+ XCTAssertFalse(reasons.contains(.payloadCRCUnverifiable))
+ XCTAssertEqual(FrameRejectReason.allCases.count, 7)
+ }
+
+ func testValidFrameCarriesNoReason() {
+ XCTAssertEqual(parseFrame(Self.hex(Self.w4Valid)).rejectReason, .none)
+ XCTAssertEqual(parseFrame(Self.hex(Self.w5Valid), family: .whoop5).rejectReason, .none)
+ XCTAssertEqual(verifyFrame(Self.hex(Self.w4Valid)).reason, .none)
+ }
+
+ func testMissingReasonInAnOlderSerialisedResultDecodesAsNone() throws {
+ // Capture files and hand-written fixtures predate the field; decoding must not turn strict.
+ let json = Data("""
+ {"ok":true,"typeName":"REALTIME_DATA","crcOK":true,"lenBytes":28,"rawHex":"",
+ "fields":[],"parsed":{}}
+ """.utf8)
+ let decoded = try JSONDecoder().decode(ParsedFrame.self, from: json)
+ XCTAssertEqual(decoded.rejectReason, .none)
+ }
+
+ // MARK: - Strukturelle Mindest- und Genaulänge je Gerätefamilie
+
+ func testWhoop4BelowMinimumLengthIsRejectedWithoutReadingAPacketType() {
+ for h in [Self.w4Total8, Self.w4Total9, Self.w4Total10] {
+ let frame = Self.hex(h)
+ let check = verifyFrame(frame)
+ XCTAssertFalse(check.ok, "\(h) is below the 11-byte minimum")
+ XCTAssertEqual(check.reason, .belowMinimumLength, "\(h)")
+ let parsed = parseFrame(frame, collectFields: true)
+ XCTAssertFalse(parsed.ok, "\(h)")
+ XCTAssertEqual(parsed.typeName, "INVALID/FRAGMENT", "no packet type is read out of \(h)")
+ XCTAssertTrue(parsed.fields.isEmpty, "\(h)")
+ XCTAssertTrue(parsed.parsed.isEmpty, "\(h)")
+ }
+ }
+
+ func testWhoop5BelowMinimumLengthIsRejectedInsteadOfReadingTheTrailer() {
+ let frame = Self.hex(Self.w5Total12)
+ let check = verifyFrame(frame, family: .whoop5)
+ XCTAssertFalse(check.ok)
+ XCTAssertEqual(check.reason, .belowMinimumLength)
+ // Its header checksum is genuinely correct (recomputed here, since a frame this short is not
+ // read at all any more), so only the structural minimum stands between it and the gates —
+ // and its "inner packet type" would be the first byte of its own CRC32 trailer.
+ XCTAssertEqual(crc16Modbus(Array(frame[0..<6])), UInt16(frame[6]) | (UInt16(frame[7]) << 8))
+ XCTAssertNil(check.crc8OK, "nothing is read out of a frame below the minimum")
+ let parsed = parseFrame(frame, family: .whoop5, collectFields: true)
+ XCTAssertEqual(parsed.typeName, "INVALID/FRAGMENT")
+ XCTAssertTrue(parsed.parsed.isEmpty)
+ }
+
+ func testFramesExactlyAtEachFamilyMinimumStayValid() {
+ // 11 bytes, from the capture corpus — the smallest real WHOOP 4.0 frame there is.
+ let w4 = Self.hex(Self.w4MetaHistoryStart11)
+ XCTAssertEqual(w4.count, FrameLimits.whoop4MinimumFrameBytes)
+ XCTAssertTrue(verifyFrame(w4).ok)
+ // Its metadata type occupies the LAST payload byte; the bound must not swallow it, or the
+ // offload would never learn that a chunk ended.
+ XCTAssertEqual(parseFrame(w4).parsed["meta_type"], .string("HISTORY_START(1)"))
+
+ let w5 = Self.hex(Self.w5Min13Metadata)
+ XCTAssertEqual(w5.count, FrameLimits.whoop5MinimumFrameBytes)
+ XCTAssertTrue(verifyFrame(w5, family: .whoop5).ok)
+ }
+
+ func testOneByteTooManyIsRejected() {
+ for (h, family) in [(Self.w4Valid, DeviceFamily.whoop4), (Self.w5Valid, .whoop5)] {
+ let frame = Self.hex(h) + [0x00]
+ let check = verifyFrame(frame, family: family)
+ XCTAssertFalse(check.ok, "trailing bytes must be rejected (\(family))")
+ XCTAssertEqual(check.reason, .lengthMismatch, "\(family)")
+ // The payload CRC still verifies — the envelope is what is wrong, and the diagnostic
+ // must say so rather than blame the payload.
+ XCTAssertEqual(check.crc32OK, true, "\(family)")
+ XCTAssertFalse(parseFrame(frame, family: family).ok, "\(family)")
+ }
+ }
+
+ func testOneByteTooFewIsRejected() {
+ for (h, family) in [(Self.w4Valid, DeviceFamily.whoop4), (Self.w5Valid, .whoop5)] {
+ let frame = Array(Self.hex(h).dropLast())
+ let check = verifyFrame(frame, family: family)
+ XCTAssertFalse(check.ok, "a truncated frame must be rejected (\(family))")
+ XCTAssertEqual(check.reason, .lengthMismatch, "\(family)")
+ XCTAssertNil(check.crc32OK, "a CRC over bytes we do not have is not computed (\(family))")
+ XCTAssertFalse(parseFrame(frame, family: family).ok, "\(family)")
+ }
+ }
+
+ // MARK: - Innere Felder werden nur aus Nutzdatenbytes gelesen
+
+ func testWhoop5AtTheMinimumDecodesNoSequenceCommandOrMetadataField() {
+ let frame = Self.hex(Self.w5Min13Metadata)
+ let parsed = parseFrame(frame, family: .whoop5, collectFields: true)
+ // It passes the gate — which is exactly why the field bound has to hold on its own.
+ XCTAssertTrue(parsed.ok)
+ XCTAssertEqual(parsed.typeName, "METADATA")
+ XCTAssertNil(parsed.seq, "offset 9 is the first byte of the CRC32 trailer")
+ XCTAssertNil(parsed.parsed["meta_type"], "offset 10 is trailer, not a metadata type")
+ XCTAssertNil(parsed.parsed["cmd"])
+ XCTAssertFalse(parsed.fields.contains { $0.name == "seq" })
+ }
+
+ func testWhoop5OneByteAboveTheMinimumDecodesTheSequenceButNotTheMetadataType() {
+ let frame = Self.hex(Self.w5Meta14FakesHistoryComplete)
+ XCTAssertEqual(frame.count, 14)
+ let parsed = parseFrame(frame, family: .whoop5, collectFields: true)
+ XCTAssertTrue(parsed.ok, "the frame is fully valid — nothing but the bound protects it")
+ // Offset 9 is now a genuine payload byte.
+ XCTAssertEqual(parsed.seq, 83)
+ // Offset 10 holds 0x03 — the byte that means HISTORY_COMPLETE — but it is the first byte of
+ // the CRC32 trailer, so no metadata type is decoded from it.
+ XCTAssertEqual(frame[10], 3)
+ XCTAssertNil(parsed.parsed["meta_type"],
+ "a frame must not be able to forge a chunk state out of its own checksum")
+ XCTAssertNil(parsed.parsed["cmd"])
+ }
+
+ func testWhoop4WithAShortenedInnerRecordStopsAtTheTrailer() {
+ let frame = Self.hex(Self.w4ShortRealtime14)
+ XCTAssertEqual(frame.count, 14)
+ let parsed = parseFrame(frame, collectFields: true)
+ XCTAssertTrue(parsed.ok, "valid header checksum, valid CRC32, exact length")
+ XCTAssertEqual(parsed.typeName, "REALTIME_DATA")
+ // The declared length is 10, so the trailer starts at 10.
+ XCTAssertEqual(parsed.parsed["timestamp"], .int(0x44332211), "offset 6..10 is payload")
+ XCTAssertNil(parsed.parsed["subseconds"], "offset 10 is trailer")
+ XCTAssertNil(parsed.parsed["heart_rate"], "offset 12 is trailer")
+ XCTAssertNil(parsed.parsed["rr_count"], "offset 13 is trailer")
+ }
+
+ func testDeclaredLengthFarBeyondTheBytesReadsNothingThatIsNotThere() {
+ // 11 bytes present, ~60 KB declared. The bound is the MINIMUM of the trailer start and the
+ // real size, so it falls back on the 11 bytes we hold. Reaching for the declared trailer
+ // start instead would read off the end of the buffer.
+ let frame = Self.hex(Self.w4HugeDeclared11)
+ let check = verifyFrame(frame)
+ XCTAssertEqual(check.length, 61440)
+ XCTAssertFalse(check.ok)
+ XCTAssertEqual(check.reason, .lengthMismatch)
+ XCTAssertNil(check.crc32OK)
+ let parsed = parseFrame(frame, collectFields: true)
+ XCTAssertFalse(parsed.ok)
+ for field in parsed.fields {
+ XCTAssertLessThanOrEqual(field.off + field.len, frame.count,
+ "field \(field.name) reaches past the bytes we hold")
+ }
+ // The same shape below the family minimum — the usual on-the-wire case, a fragment whose
+ // length word promises thousands of bytes — decodes no field at all.
+ let fragment = Array(frame.dropLast())
+ let fragmentParsed = parseFrame(fragment, collectFields: true)
+ XCTAssertTrue(fragmentParsed.fields.isEmpty)
+ XCTAssertTrue(fragmentParsed.parsed.isEmpty)
+ XCTAssertEqual(fragmentParsed.rejectReason, .belowMinimumLength)
+ }
+
+ func testAcknowledgementBlockOfTheRealHistoryEndFrameIsUnchanged() {
+ // The 8-byte block the trim acknowledgement mirrors back to the strap is NOT a decoded
+ // field: it is an opaque echo, and by construction it reaches into the CRC32 trailer. The
+ // field bound must never be applied to it — a clipped block either makes the strap refuse
+ // the acknowledgement (the offload stops) or makes it trim on altered bytes.
+ let frame = Self.hex(Self.w4HistoryEnd25)
+ XCTAssertEqual(frame.count, 25)
+ let check = verifyFrame(frame)
+ XCTAssertTrue(check.ok)
+ XCTAssertEqual(check.length, 21, "the CRC32 trailer starts at 21 …")
+ // … and the block runs 17..25, four bytes of it inside that trailer. Derive the block from
+ // the frame rather than from a second literal: comparing one spelling of the fixture with
+ // another cannot fail, and this assertion exists to fail when a later tidy-up narrows the
+ // block. The literal pin stays as the byte-level record of what the strap is echoed.
+ let ackStart = 25 - 8
+ let block = Array(frame.suffix(8))
+ XCTAssertEqual(block, Array(frame[ackStart..<25]))
+ XCTAssertEqual(block, Self.hex("39300000c3401bdb"))
+ // The exception has to BE an exception: the block must straddle the D7 bound, or this test
+ // would be pinning an ordinary payload read and would go on passing after a clip.
+ let bound = check.length!
+ XCTAssertLessThan(ackStart, bound, "the block starts inside the payload …")
+ XCTAssertGreaterThan(25, bound, "… and ends inside the CRC32 trailer")
+ // What a clipped block would look like, spelled out so the difference is not a matter of
+ // reading: applying the field bound would hand the strap four bytes instead of eight.
+ XCTAssertNotEqual(block, Array(frame[ackStart.. Data {
+ let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json"),
+ "missing \(name).json test resource")
+ return try Data(contentsOf: url)
+ }
+
+ /// Every real frame the package's capture corpus holds must keep a POSITIVE verdict under the
+ /// tightened rules — minimum length, exact length, header checksum and payload CRC32 together.
+ /// This is the regression guard for the whole change: the corpus is what a strap actually sent.
+ func testEveryRecordedFrameInTheCorpusStaysValid() throws {
+ var checked = 0
+ func assertValid(_ hexString: String, _ family: DeviceFamily, _ label: String) {
+ let frame = Self.hex(hexString)
+ let check = verifyFrame(frame, family: family)
+ XCTAssertTrue(check.ok, "\(label) (\(frame.count) B) rejected: \(check.reason)")
+ XCTAssertEqual(check.reason, .none, "\(label)")
+ XCTAssertTrue(parseFrame(frame, family: family).ok, "\(label)")
+ checked += 1
+ }
+
+ let dec = JSONDecoder()
+ for (i, e) in try dec.decode([HexEntry].self, from: resource("frames")).enumerated() {
+ assertValid(e.hex, .whoop4, "frames.json #\(i)")
+ }
+ for (i, e) in try dec.decode([HexEntry].self, from: resource("historical_frames")).enumerated() {
+ assertValid(e.hex, .whoop4, "historical_frames.json #\(i)")
+ }
+ for (i, e) in try dec.decode(DecoderOracleFile.self, from: resource("decoder_oracle")).frames.enumerated() {
+ assertValid(e.hex, e.family == "whoop5" ? .whoop5 : .whoop4, "decoder_oracle.json #\(i)")
+ }
+ for (i, e) in try dec.decode(OpticalOracleFile.self,
+ from: resource("r20_optical_oracle")).records.enumerated() {
+ assertValid(e.hex, .whoop5, "r20_optical_oracle.json #\(i)")
+ }
+ XCTAssertEqual(checked, 116, "the corpus size is pinned so a lost resource cannot pass as green")
+ }
+}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift
index f8c6707e8e..8e3804c741 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FramingTests.swift
@@ -69,7 +69,9 @@ final class FramingTests: XCTestCase {
// inner = [43,0,0] + data (7 bytes); length = 7 + 4 = 11.
XCTAssertEqual(frame[0], 0xAA)
XCTAssertEqual(Int(frame[1]) | (Int(frame[2]) << 8), 11)
- XCTAssertEqual(frame[3], 0x00) // placeholder crc8
+ // The header checksum is computed for real (it used to be a 0x00 placeholder), so a rebuilt
+ // frame is one a strap could have sent rather than one every gate now rejects.
+ XCTAssertEqual(frame[3], crc8([frame[1], frame[2]]))
XCTAssertEqual(frame[4], 43)
XCTAssertEqual(frame[5], 0)
XCTAssertEqual(frame[6], 0)
@@ -80,8 +82,12 @@ final class FramingTests: XCTestCase {
let got = UInt32(frame[11]) | (UInt32(frame[12]) << 8)
| (UInt32(frame[13]) << 16) | (UInt32(frame[14]) << 24)
XCTAssertEqual(got, want)
- // The reconstructed frame's crc32 must verify (crc8 is a placeholder so .ok stays false).
- XCTAssertEqual(verifyFrame(frame).crc32OK, true)
+ // The reconstructed frame verifies end to end: both checksums AND the exact length.
+ let check = verifyFrame(frame)
+ XCTAssertEqual(check.crc32OK, true)
+ XCTAssertEqual(check.crc8OK, true)
+ XCTAssertTrue(check.ok)
+ XCTAssertEqual(check.reason, .none)
}
func testFrameFromPayloadDefaults() {
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift
index a57d7c1ab3..642537f836 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalMetaTests.swift
@@ -107,4 +107,109 @@ final class HistoricalMetaTests: XCTestCase {
let p = metaParsed(cmd: 3)
XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_COMPLETE(3)"))
}
+
+ // MARK: - the gate: a metadata frame that is not INTACT classifies as nothing
+ //
+ // Scenario "Zustandstreibende Tore fordern das volle Urteil / Verlaufs-Metadaten können nicht
+ // gefälscht werden". Each frame below is a real, decodable HISTORY_END / HISTORY_COMPLETE broken in
+ // exactly ONE way, so what the classifier refuses is attributable. `.end`/`.complete` are the two
+ // results that advance the trim cursor and ack the strap to free its records, which is why they are
+ // the ones a forged frame must never reach.
+
+ /// Header checksum wrong, payload CRC32 RIGHT — the class that passed every gate before this change.
+ func testHistoryEndWithABrokenHeaderChecksumIsNotClassified() {
+ let payload: [UInt8] = le32(1_700_000_000) + le16(0) + le32(0) + le32(4242)
+ var frame = frameFromPayload(payload, type: 49, seq: 0, cmd: 2)
+ frame[3] ^= 0xFF // CRC-8 over the length field only
+ let p = parseFrame(frame)
+ XCTAssertEqual(p.crcOK, true, "precondition: only the HEADER checksum is broken")
+ XCTAssertEqual(p.rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(p.typeName, "METADATA", "the frame stays readable for an inspector …")
+ XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_END(2)"), "… including its meta type")
+ XCTAssertEqual(classifyHistoricalMeta(p), .other,
+ "… but it must not advance the trim cursor or ack the strap")
+ }
+
+ func testHistoryCompleteWithABrokenHeaderChecksumIsNotClassified() {
+ var frame = frameFromPayload([], type: 49, seq: 0, cmd: 3)
+ frame[3] ^= 0xFF
+ let p = parseFrame(frame)
+ XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_COMPLETE(3)"))
+ XCTAssertEqual(classifyHistoricalMeta(p), .other)
+ }
+
+ /// Declared length below the WHOOP 4.0 minimum of 11 total bytes. The bytes are all still there —
+ /// only the length word claims a frame too small to hold an inner record.
+ func testHistoryEndWithADeclaredLengthBelowTheMinimumIsNotClassified() {
+ let payload: [UInt8] = le32(1_700_000_000) + le16(0) + le32(0) + le32(4242)
+ var frame = frameFromPayload(payload, type: 49, seq: 0, cmd: 2)
+ frame[1] = 6; frame[2] = 0 // declared 6 → total 10, below the 11-byte floor
+ frame[3] = crc8(frame, 1, 3) // …with a CORRECT header checksum for that word
+ let p = parseFrame(frame)
+ XCTAssertEqual(p.rejectReason, .belowMinimumLength)
+ XCTAssertEqual(classifyHistoricalMeta(p), .other)
+ }
+
+ /// The CRC32 trailer is cut off: the declared length promises four bytes that are not there.
+ func testHistoryEndWithATruncatedTrailerIsNotClassified() {
+ let payload: [UInt8] = le32(1_700_000_000) + le16(0) + le32(0) + le32(4242)
+ let full = frameFromPayload(payload, type: 49, seq: 0, cmd: 2)
+ let cut = Array(full.dropLast(2)) // still >= 11 bytes, so it parses
+ let p = parseFrame(cut)
+ XCTAssertEqual(p.rejectReason, .lengthMismatch)
+ XCTAssertEqual(p.typeName, "METADATA")
+ XCTAssertEqual(p.parsed["meta_type"], .string("HISTORY_END(2)"),
+ "precondition: without the gate this frame WOULD be read as a history end")
+ XCTAssertEqual(classifyHistoricalMeta(p), .other)
+ }
+
+ /// Trailing bytes past the frame's own end — the other half of the exact-length rule.
+ func testHistoryCompleteWithTrailingBytesIsNotClassified() {
+ let frame = frameFromPayload([], type: 49, seq: 0, cmd: 3) + [0x00, 0x00]
+ let p = parseFrame(frame)
+ XCTAssertEqual(p.rejectReason, .lengthMismatch)
+ XCTAssertEqual(classifyHistoricalMeta(p), .other)
+ }
+
+ /// The counterpart that must NOT regress: the smallest REAL metadata frames in the capture corpus
+ /// sit exactly on the 11-byte minimum, with the meta type in their last payload byte. If the bound
+ /// or the gate were one byte stricter, the offload would never see a history end and never finish.
+ func testRealElevenByteMetadataFramesStillClassify() {
+ let start = FrameIntegrityTests.hex(FrameIntegrityTests.w4MetaHistoryStart11)
+ let complete = FrameIntegrityTests.hex(FrameIntegrityTests.w4MetaHistoryComplete11)
+ XCTAssertEqual(classifyHistoricalMeta(parseFrame(start)), .start)
+ XCTAssertEqual(classifyHistoricalMeta(parseFrame(complete)), .complete)
+ }
+
+ /// And the real 25-byte HISTORY_END still yields its unix + trim cursor.
+ func testRealHistoryEndFrameStillClassifies() {
+ let p = parseFrame(FrameIntegrityTests.hex(FrameIntegrityTests.w4HistoryEnd25))
+ guard case .end(let unix, let trim) = classifyHistoricalMeta(p) else {
+ return XCTFail("the real HISTORY_END frame must still classify as an end, got \(classifyHistoricalMeta(p))")
+ }
+ XCTAssertEqual(unix, 1_700_000_000)
+ XCTAssertEqual(trim, 12345)
+ }
+
+ // MARK: - a metadata TYPE is never read out of the CRC32 trailer (D7, shared with package 1)
+
+ /// Scenario "Kein Byte des CRC-Anhangs wird als Metadatentyp gelesen". This 14-byte WHOOP 5.0 frame
+ /// is fully INTACT — correct header checksum, correct CRC32, exact length — and the first byte of
+ /// its own trailer sits where the metadata type would be read, carrying the value 3
+ /// (HISTORY_COMPLETE). Only the payload bound stops it, so this is the case that would end an
+ /// offload on a forged frame the integrity gate cannot object to.
+ func testWhoop5FrameAtTheMinimumCannotFakeHistoryComplete() {
+ let frame = FrameIntegrityTests.hex(FrameIntegrityTests.w5Meta14FakesHistoryComplete)
+ let p = parseFrame(frame, family: .whoop5)
+ XCTAssertTrue(p.ok, "precondition: the gate has no objection to this frame at all")
+ XCTAssertEqual(p.typeName, "METADATA")
+ XCTAssertNil(p.parsed["meta_type"], "the type byte would come from the CRC32 trailer")
+ XCTAssertEqual(classifyHistoricalMeta(p), .other)
+ }
+
+ func testWhoop5FrameAtTheMinimumLengthClassifiesAsNothing() {
+ let p = parseFrame(FrameIntegrityTests.hex(FrameIntegrityTests.w5Min13Metadata), family: .whoop5)
+ XCTAssertTrue(p.ok, "precondition: 13 bytes is the family minimum, not a rejection")
+ XCTAssertEqual(classifyHistoricalMeta(p), .other)
+ }
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift
index 32eef06afb..b87622e30b 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalV24Tests.swift
@@ -73,7 +73,12 @@ final class HistoricalV24Tests: XCTestCase {
var bad = bytes(v24Hex)
bad[5] = 99 // flip version byte to an unmapped value
let out = parseFrame(bad)
- XCTAssertTrue(out.ok) // parse is defensive (crc will mismatch)
+ // The flipped byte is inside the CRC32-covered inner record, so the frame is NOT intact and
+ // `ok` says so — it used to report a positive verdict for exactly this deliberately corrupted
+ // frame. Decoding stays defensive: the fields are still readable for an inspector.
+ XCTAssertFalse(out.ok)
+ XCTAssertEqual(out.rejectReason, .payloadCRCMismatch)
+ XCTAssertEqual(out.crcOK, false)
XCTAssertEqual(out.parsed["hist_version"]?.intValue, 99)
}
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift
index 0156e48ec2..cd29a06322 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReToolsTests.swift
@@ -119,7 +119,29 @@ final class ReToolsTests: XCTestCase {
XCTAssertTrue(g.unknownBytes.allSatisfy { $0.constant })
}
- // MARK: - C) inventory: census sorted by count, ok/crc, ts span, len spread
+ // MARK: - 2.13: the hand-built parse result still decodes after the new field appeared
+
+ // ReTools' fixtures build a ParsedFrame by decoding a JSON dict that has no `rejectReason` key at
+ // all — the same shape older capture files have. Decoding must stay TOLERANT and default it to
+ // `.none`, or every fixture in this file (and every archived capture) becomes undecodable.
+ func testHandBuiltParseResultDecodesWithoutARejectReason() {
+ let f = frame(type: "EVENT", len: 4)
+ XCTAssertEqual(f.rejectReason, .none,
+ "a document without the key must decode, defaulting the reason to none")
+ // And a document that DOES carry one keeps it, so the field is genuinely read and not ignored.
+ let dict: [String: Any] = [
+ "ok": false, "typeName": "EVENT", "seq": NSNull(), "cmdName": NSNull(),
+ "crcOK": true, "lenBytes": 12, "rawHex": "", "fields": [], "parsed": [:],
+ "rejectReason": "headerChecksumMismatch",
+ ]
+ let data = try! JSONSerialization.data(withJSONObject: dict)
+ let decoded = try! JSONDecoder().decode(ParsedFrame.self, from: data)
+ XCTAssertEqual(decoded.rejectReason, .headerChecksumMismatch)
+ XCTAssertTrue(decoded.payloadCRCOKButEnvelopeRejected,
+ "payload CRC32 right + envelope wrong is the class that used to pass the gates")
+ }
+
+ // MARK: - C) inventory: census sorted by count, intact/parsable/crc, ts span, len spread
func testInventoryCensus() {
let hist = frame(type: "HISTORICAL_DATA", seq: 26, len: 8, ok: true, crcOK: true)
@@ -133,8 +155,11 @@ final class ReToolsTests: XCTestCase {
XCTAssertEqual(inv.first?.key, "HISTORICAL_DATA/v26") // 3 records → most common, sorts first
let h = inv.first!
XCTAssertEqual(h.count, 3)
- XCTAssertEqual(h.okCount, 2)
+ XCTAssertEqual(h.intactCount, 2)
XCTAssertEqual(h.crcOkCount, 2)
+ // All three carry a decoded packet type, including the one that is not intact: parseability and
+ // integrity are separate columns, and the census must not report a readable frame as unreadable.
+ XCTAssertEqual(h.parsableCount, 3)
XCTAssertEqual(h.firstTsMs, 1000)
XCTAssertEqual(h.lastTsMs, 3000)
XCTAssertEqual(h.minLen, 6)
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift
index cb6023ea9b..5d50d38de0 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/RejectedHistoryTests.swift
@@ -77,6 +77,31 @@ final class RejectedHistoryTests: XCTestCase {
XCTAssertTrue(rejectedHistoricalRecords([v26], family: .whoop5).isEmpty)
}
+ /// The v26 skip is bound to the VERDICT, not to the version byte alone. Its whole premise is that
+ /// `extractHistoricalStreams` stores such a record durably in the PPG waveform stream — which stops
+ /// being true the moment the record is rejected: the extraction drops it, and an unconditional skip
+ /// here would leave it archived nowhere while the section is acked anyway.
+ func testWhoop5V26RecordWithABrokenHeaderChecksumIsArchived() {
+ var bad = bytes(whoop5V26Hex)
+ bad[6] ^= 0xFF // CRC-16-Modbus over the first six bytes
+ XCTAssertEqual(bad[8], 47) // still a HISTORICAL_DATA record…
+ XCTAssertEqual(bad[9], 26) // …still version 26
+ let p = parseFrame(bad, family: .whoop5)
+ XCTAssertEqual(p.crcOK, true, "precondition: the PAYLOAD CRC32 still verifies")
+ XCTAssertEqual(p.rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop5), [bad],
+ "a rejected v26 record reaches no stream, so its bytes are the only copy left")
+ }
+
+ /// The other direction of the same rule: binding the skip to the verdict must not start archiving
+ /// the NORMAL case. An intact v26 record is stored in its own stream and stays out of the archive.
+ func testIntactWhoop5V26RecordIsStillNotArchived() {
+ let v26 = bytes(whoop5V26Hex)
+ XCTAssertTrue(parseFrame(v26, family: .whoop5).ok, "precondition: the record is intact")
+ XCTAssertTrue(rejectedHistoricalRecords([v26], family: .whoop5).isEmpty,
+ "the archive must not grow by the normal case")
+ }
+
func testNonHistoricalFrameExcluded() {
// A REALTIME_DATA (type-40) frame is live, not offload — never a history-loss candidate.
let realtime = frameFromPayload([0x01, 0x02, 0x03], type: 40, seq: 0, cmd: 0)
@@ -98,6 +123,75 @@ final class RejectedHistoryTests: XCTestCase {
XCTAssertEqual(rejected, [bad])
}
+ // MARK: - D8: this reader runs the OTHER WAY ROUND — a negative verdict means ARCHIVE
+
+ /// Scenario "Beweissichernde Leser verlieren keine Rahmen / Rahmen mit kaputtem Header wird
+ /// gesichert statt verworfen". Before the change this frame passed as decodable and was archived
+ /// NOWHERE; the strap frees it on the next trim ack, so the archive is the only copy that can exist.
+ func testWhoop4RecordWithABrokenHeaderChecksumIsArchived() {
+ var bad = bytes(v24Hex)
+ bad[3] ^= 0xFF // CRC-8 over the length field only
+ let p = parseFrame(bad)
+ XCTAssertEqual(p.crcOK, true, "precondition: the PAYLOAD CRC32 still verifies")
+ XCTAssertEqual(p.rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop4), [bad],
+ "a frame we cannot trust is a frame we must keep the bytes of")
+ }
+
+ func testWhoop5RecordWithABrokenHeaderChecksumIsArchived() {
+ var bad = bytes(whoop5V18Hex)
+ bad[6] ^= 0xFF // CRC-16-Modbus over the first six bytes
+ XCTAssertEqual(bad[8], 47)
+ XCTAssertEqual(parseFrame(bad, family: .whoop5).rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop5), [bad])
+ }
+
+ func testWhoop4RecordWithTrailingBytesIsArchived() {
+ let bad = bytes(v24Hex) + [0x00] // one byte past the frame's own end
+ XCTAssertEqual(parseFrame(bad).rejectReason, .lengthMismatch)
+ XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop4), [bad])
+ }
+
+ func testWhoop4RecordWithATruncatedTrailerIsArchived() {
+ let bad = Array(bytes(v24Hex).dropLast(2))
+ XCTAssertEqual(parseFrame(bad).rejectReason, .lengthMismatch)
+ XCTAssertEqual(rejectedHistoricalRecords([bad], family: .whoop4), [bad])
+ }
+
+ /// Scenario "Bisher gesicherte Rahmen bleiben gesichert", over the real capture corpus.
+ ///
+ /// The pre-change reader is re-implemented here verbatim, with the one substitution the change
+ /// makes: its `ok` was a constant that meant "these bytes read as a frame", which is
+ /// `isParsable` today. Every frame it would have archived must still be archived. The direction
+ /// is asserted as a SUBSET, not as equality, because the new reader is expected to archive more.
+ func testNoCorpusFrameIsArchivedLessThanBefore() throws {
+ let url = try XCTUnwrap(Bundle.module.url(forResource: "frames", withExtension: "json"),
+ "the WHOOP 4.0 capture corpus must be present")
+ struct HexOnly: Decodable { let hex: String }
+ let corpus = try JSONDecoder().decode([HexOnly].self, from: Data(contentsOf: url))
+ .map { bytes($0.hex) }
+ XCTAssertGreaterThan(corpus.count, 50, "precondition: a corpus worth calling a corpus")
+
+ // The pre-change predicate, for WHOOP 4.0.
+ func archivedBefore(_ f: [UInt8]) -> Bool {
+ guard f.count > 4, Int(f[4]) == 47 else { return false }
+ let p = parseFrame(f, family: .whoop4)
+ if !p.isParsable || p.crcOK == false { return true }
+ return p.parsed["unix"]?.intValue == nil
+ || (p.parsed["heart_rate"]?.intValue == nil && p.parsed["gravity_x"]?.doubleValue == nil)
+ }
+
+ let before = corpus.filter(archivedBefore)
+ let now = Set(rejectedHistoricalRecords(corpus, family: .whoop4).map { Data($0) })
+ for f in before {
+ XCTAssertTrue(now.contains(Data(f)),
+ "a frame archived before this change must still be archived: \(f.prefix(8))")
+ }
+ // And nothing INTACT and decodable was dragged in: the corpus's clean records stay out.
+ let clean = corpus.filter { $0.count > 4 && Int($0[4]) == 47 && parseFrame($0).ok }
+ XCTAssertGreaterThan(clean.count, 0, "precondition: the corpus holds intact type-47 records")
+ }
+
func testIsEmptyRecordFrameFlagsAllZeroPayloadOnly() {
// A 104 B frame with header + CRC bytes set but the record payload (21.. empty.
var empty = [UInt8](repeating: 0, count: 104)
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/frame_integrity_oracle.json b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/frame_integrity_oracle.json
new file mode 100644
index 0000000000..541873e8f4
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/frame_integrity_oracle.json
@@ -0,0 +1,498 @@
+{
+ "_comment": [
+ "SHARED FRAME-INTEGRITY ORACLE (change frame-integrity, D5 / task 4.2).",
+ "Generated — do not hand-edit. Regenerate with:",
+ "swift run --package-path openspec/changes/frame-integrity/oracle-gen FrameIntegrityOracleGen",
+ "Two byte-identical copies are committed, one per test tree, and both suites assert every case",
+ "and all three pinned fields (verdict, reject_reason, meta), so neither platform can move its",
+ "verifier without the other's suite going red.",
+ "verdict = ParsedFrame.ok, the verifier's FULL verdict.",
+ "reject_reason = the Swift FrameRejectReason rawValue; Kotlin maps its enum through wireName.",
+ "meta = classifyHistoricalMeta, as start | complete | other | end(unix=U,trim=T).",
+ "ecg_inner_payload_hex is present only on cases with ecg_path true (the second verifying path,",
+ "Whoop5Ecg.innerPayload); null means that path refused the frame.",
+ "The export FORM of a reject reason differs by design (Kotlin omits the key when there is no",
+ "reason, Swift always writes it) — this oracle pins the verdict, the reason and the",
+ "classification, never the export spelling."
+ ],
+ "coverage": {
+ "case_count": 45,
+ "classes": {
+ "below_minimum": 4,
+ "boundary": 3,
+ "ecg_path": 3,
+ "evaluation_order": 4,
+ "header_corrupt": 5,
+ "no_start_of_frame": 4,
+ "payload_corrupt": 5,
+ "trailing_bytes": 3,
+ "truncated": 4,
+ "valid_recorded": 9,
+ "valid_synthetic": 1
+ },
+ "reasons": {
+ "belowMinimumLength": 5,
+ "headerChecksumMismatch": 7,
+ "lengthMismatch": 10,
+ "noStartOfFrame": 4,
+ "none": 14,
+ "payloadCRCMismatch": 5
+ }
+ },
+ "cases": [
+ {
+ "name": "w4_realtime_valid",
+ "family": "whoop4",
+ "class": "valid_recorded",
+ "hex": "aa1800ff28020f3de10128663c0000000000000000000000da855212",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_meta_history_start_11",
+ "family": "whoop4",
+ "class": "valid_recorded",
+ "hex": "aa07006b3100012366efad",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "start",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_meta_history_complete_11",
+ "family": "whoop4",
+ "class": "valid_recorded",
+ "hex": "aa07006b3100030f07e143",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "complete",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_history_end_25",
+ "family": "whoop4",
+ "class": "valid_recorded",
+ "hex": "aa15001631000200f1536500000000000039300000c3401bdb",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "end(unix=1700000000,trim=12345)",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_historical_v24_valid",
+ "family": "whoop4",
+ "class": "valid_recorded",
+ "hex": "aa6400a12f18054c1c0a023ed0266a5037805418016d022b0234020000000000006b07ff0085593c1f65cebed7b3e63eb85a5f3f000080401f65cebed7b3e63eb85a5f3f500264025d03640229014009010c020c00000000000f0001c4020000000000008fdeb278",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_historical_v25_valid",
+ "family": "whoop4",
+ "class": "valid_recorded",
+ "hex": "aa50000c2f1900006800007dff2a6a20430900433103007e026502ba026c022eff70f996f879fad6fd8300d6017e0267027201be00290258030e05c507f00c030ead11cb15791500d2553c9003000000d6393716",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_historical_v18_valid",
+ "family": "whoop5",
+ "class": "valid_recorded",
+ "hex": "aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000000000000000f7000901f10b0007010c020c00000000000000000000000000000000000000000000000100656f1e1e0000009d61a7c00000003e862817",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_meta_history_end_valid",
+ "family": "whoop5",
+ "class": "valid_recorded",
+ "hex": "aa011c00010023d1316a0284a3266a0a373d00000041b601001000000000000044d21e3d",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "end(unix=1780917124,trim=112193)",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_meta_history_start_valid",
+ "family": "whoop5",
+ "class": "valid_recorded",
+ "hex": "aa012c0001002cd1312c0184a3266ad7230d0000005200000000000000320000002600000001000000000000000b010034497926",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "start",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_command_response_valid",
+ "family": "whoop5",
+ "class": "valid_synthetic",
+ "hex": "aa010c000001e74124070211223344557481f36e",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_realtime_header_checksum_corrupt",
+ "family": "whoop4",
+ "class": "header_corrupt",
+ "hex": "aa18000028020f3de10128663c0000000000000000000000da855212",
+ "verdict": false,
+ "reject_reason": "headerChecksumMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_history_end_25_header_corrupt",
+ "family": "whoop4",
+ "class": "header_corrupt",
+ "hex": "aa1500e931000200f1536500000000000039300000c3401bdb",
+ "verdict": false,
+ "reject_reason": "headerChecksumMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_meta_complete_11_header_corrupt",
+ "family": "whoop4",
+ "class": "header_corrupt",
+ "hex": "aa0700943100030f07e143",
+ "verdict": false,
+ "reject_reason": "headerChecksumMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_command_response_header_crc16_corrupt",
+ "family": "whoop5",
+ "class": "header_corrupt",
+ "hex": "aa010c000001184124070211223344557481f36e",
+ "verdict": false,
+ "reject_reason": "headerChecksumMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_meta_history_end_header_corrupt",
+ "family": "whoop5",
+ "class": "header_corrupt",
+ "hex": "aa011c000100dcd1316a0284a3266a0a373d00000041b601001000000000000044d21e3d",
+ "verdict": false,
+ "reject_reason": "headerChecksumMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_realtime_payload_corrupt",
+ "family": "whoop4",
+ "class": "payload_corrupt",
+ "hex": "aa1800ff28020f3d1e0128663c0000000000000000000000da855212",
+ "verdict": false,
+ "reject_reason": "payloadCRCMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_history_end_25_payload_corrupt",
+ "family": "whoop4",
+ "class": "payload_corrupt",
+ "hex": "aa150016310002000e536500000000000039300000c3401bdb",
+ "verdict": false,
+ "reject_reason": "payloadCRCMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_historical_v24_payload_corrupt",
+ "family": "whoop4",
+ "class": "payload_corrupt",
+ "hex": "aa6400a12f18054c1c0a023ed0266a5037805418fe6d022b0234020000000000006b07ff0085593c1f65cebed7b3e63eb85a5f3f000080401f65cebed7b3e63eb85a5f3f500264025d03640229014009010c020c00000000000f0001c4020000000000008fdeb278",
+ "verdict": false,
+ "reject_reason": "payloadCRCMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_historical_v18_payload_corrupt",
+ "family": "whoop5",
+ "class": "payload_corrupt",
+ "hex": "aa01740001003fb12f1280733d8401b69f266a66b90066025a0265020000000000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000000000000000f7000901f10b0007010c020c00000000000000000000000000000000000000000000000100656f1e1e0000009d61a7c00000003e862817",
+ "verdict": false,
+ "reject_reason": "payloadCRCMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_meta_history_end_payload_corrupt",
+ "family": "whoop5",
+ "class": "payload_corrupt",
+ "hex": "aa011c00010023d1316a0284a3266a0a373d0000ff41b601001000000000000044d21e3d",
+ "verdict": false,
+ "reject_reason": "payloadCRCMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_total8_declared4",
+ "family": "whoop4",
+ "class": "below_minimum",
+ "hex": "aa04005400000000",
+ "verdict": false,
+ "reject_reason": "belowMinimumLength",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_total9_declared5",
+ "family": "whoop4",
+ "class": "below_minimum",
+ "hex": "aa05004131b7efdc83",
+ "verdict": false,
+ "reject_reason": "belowMinimumLength",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_total10_declared6",
+ "family": "whoop4",
+ "class": "below_minimum",
+ "hex": "aa06007e31004d158487",
+ "verdict": false,
+ "reject_reason": "belowMinimumLength",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_total12_declared4",
+ "family": "whoop5",
+ "class": "below_minimum",
+ "hex": "aa0104000001e52100000000",
+ "verdict": false,
+ "reject_reason": "belowMinimumLength",
+ "meta": "other",
+ "ecg_path": true,
+ "ecg_inner_payload_hex": null
+ },
+ {
+ "name": "w4_realtime_truncated_by_1",
+ "family": "whoop4",
+ "class": "truncated",
+ "hex": "aa1800ff28020f3de10128663c0000000000000000000000da8552",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_history_end_25_trailer_truncated",
+ "family": "whoop4",
+ "class": "truncated",
+ "hex": "aa15001631000200f1536500000000000039300000c340",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_historical_v18_truncated_by_1",
+ "family": "whoop5",
+ "class": "truncated",
+ "hex": "aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000000000000000f7000901f10b0007010c020c00000000000000000000000000000000000000000000000100656f1e1e0000009d61a7c00000003e8628",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_huge_declared_length_11_present",
+ "family": "whoop4",
+ "class": "truncated",
+ "hex": "aa00f0de31000102030405",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_realtime_trailing_byte",
+ "family": "whoop4",
+ "class": "trailing_bytes",
+ "hex": "aa1800ff28020f3de10128663c0000000000000000000000da85521200",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_meta_complete_11_trailing_byte",
+ "family": "whoop4",
+ "class": "trailing_bytes",
+ "hex": "aa07006b3100030f07e14300",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_command_response_trailing_byte",
+ "family": "whoop5",
+ "class": "trailing_bytes",
+ "hex": "aa010c000001e74124070211223344557481f36e00",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_min13_metadata_boundary",
+ "family": "whoop5",
+ "class": "boundary",
+ "hex": "aa0105000001e4dd31b7efdc83",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_meta14_fakes_history_complete",
+ "family": "whoop5",
+ "class": "boundary",
+ "hex": "aa0106000001e49931530315e675",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_short_realtime_14",
+ "family": "whoop4",
+ "class": "boundary",
+ "hex": "aa0a0082280111223344854deb31",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "no_start_of_frame_w4",
+ "family": "whoop4",
+ "class": "no_start_of_frame",
+ "hex": "bb1800ff28020f3de10128663c0000000000000000000000da855212",
+ "verdict": false,
+ "reject_reason": "noStartOfFrame",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "no_start_of_frame_w5",
+ "family": "whoop5",
+ "class": "no_start_of_frame",
+ "hex": "bb010c000001e74124070211223344557481f36e",
+ "verdict": false,
+ "reject_reason": "noStartOfFrame",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "empty_byte_run_w4",
+ "family": "whoop4",
+ "class": "no_start_of_frame",
+ "hex": "",
+ "verdict": false,
+ "reject_reason": "noStartOfFrame",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "empty_byte_run_w5",
+ "family": "whoop5",
+ "class": "no_start_of_frame",
+ "hex": "",
+ "verdict": false,
+ "reject_reason": "noStartOfFrame",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_length_field_corrupt_order_length_before_header",
+ "family": "whoop4",
+ "class": "evaluation_order",
+ "hex": "aae700ff28020f3de10128663c0000000000000000000000da855212",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_header_and_payload_both_corrupt",
+ "family": "whoop4",
+ "class": "evaluation_order",
+ "hex": "aa18000028020f3d1e0128663c0000000000000000000000da855212",
+ "verdict": false,
+ "reject_reason": "headerChecksumMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w4_below_minimum_and_header_corrupt",
+ "family": "whoop4",
+ "class": "evaluation_order",
+ "hex": "aa0400ab00000000",
+ "verdict": false,
+ "reject_reason": "belowMinimumLength",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_trailing_byte_and_header_corrupt",
+ "family": "whoop5",
+ "class": "evaluation_order",
+ "hex": "aa010c000001184124070211223344557481f36e00",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": false
+ },
+ {
+ "name": "w5_ecg_filtered_valid",
+ "family": "whoop5",
+ "class": "ecg_path",
+ "hex": "aa0120000001efd128010002050101000100012a003d3f2c031103000a00f6ff2c0100004decffd3",
+ "verdict": true,
+ "reject_reason": "none",
+ "meta": "other",
+ "ecg_path": true,
+ "ecg_inner_payload_hex": "02050101000100012a003d3f2c031103000a00f6ff2c010000"
+ },
+ {
+ "name": "w5_ecg_filtered_trailing_byte",
+ "family": "whoop5",
+ "class": "ecg_path",
+ "hex": "aa0120000001efd128010002050101000100012a003d3f2c031103000a00f6ff2c0100004decffd300",
+ "verdict": false,
+ "reject_reason": "lengthMismatch",
+ "meta": "other",
+ "ecg_path": true,
+ "ecg_inner_payload_hex": null
+ },
+ {
+ "name": "w5_ecg_header_crc16_corrupt",
+ "family": "whoop5",
+ "class": "ecg_path",
+ "hex": "aa012000000110d128010002050101000100012a003d3f2c031103000a00f6ff2c0100004decffd3",
+ "verdict": false,
+ "reject_reason": "headerChecksumMismatch",
+ "meta": "other",
+ "ecg_path": true,
+ "ecg_inner_payload_hex": null
+ }
+ ]
+}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ScanAdvertisementSummaryTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ScanAdvertisementSummaryTests.swift
new file mode 100644
index 0000000000..02c635ddac
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ScanAdvertisementSummaryTests.swift
@@ -0,0 +1,106 @@
+import Foundation
+import XCTest
+@testable import WhoopProtocol
+
+/// The advertisement summary (#1635) — the twin of the Kotlin `ScanAdvertisementSummaryTest`, same
+/// cases and same expected text so the two platforms cannot describe a strap differently.
+///
+/// Its job is to make two advertising modes distinguishable in a strap log without carrying anything
+/// that identifies a person or a device. WHOOP names a strap "'s Whoop" by default, so the local
+/// name is the one field that must never appear.
+final class ScanAdvertisementSummaryTests: XCTestCase {
+
+ private func line(flags: Int? = 0x06,
+ svc: [String] = ["61080001-8d6d-82b8-614a-1c8cb0f8dcc6"],
+ svcData: [String: Int] = [:],
+ mfg: [Int: Int] = [:],
+ tx: Int? = nil,
+ nameLen: Int? = 12,
+ connectable: Bool = true) -> String {
+ ScanAdvertisementSummary.line(flags: flags, serviceUuids: svc, serviceDataLengths: svcData,
+ manufacturerDataLengths: mfg, txPower: tx,
+ localNameLength: nameLen, connectable: connectable)
+ }
+
+ /// The headline guarantee: shape is reported, payload never is.
+ func testSummaryCarriesNoPayloadBytesAndNoName() {
+ let s = line(svcData: ["fd4b": 9], mfg: [0x01D9: 14], nameLen: 13)
+ XCTAssertTrue(s.contains("0000fd4b-0000-1000-8000-00805f9b34fb:9B"))
+ XCTAssertTrue(s.contains("0x01d9:14B"))
+ XCTAssertTrue(s.contains("nameLen=13"))
+ XCTAssertFalse(s.contains("Whoop"))
+ XCTAssertFalse(s.contains("'s"))
+ }
+
+ /// The point of the line: two advertising modes must produce different text, or the #1635 question
+ /// stays unanswerable.
+ func testDifferentAdvertisingModeReadsDifferently() {
+ let normal = line(flags: 0x06, svcData: [:])
+ let pairing = line(flags: 0x05, svcData: ["fd4b": 4])
+ XCTAssertNotEqual(normal, pairing)
+ XCTAssertTrue(normal.contains("flags=0x06"))
+ XCTAssertTrue(pairing.contains("flags=0x05"))
+ XCTAssertTrue(normal.contains("svcData=none"))
+ XCTAssertTrue(pairing.contains("0000fd4b-0000-1000-8000-00805f9b34fb:4B"))
+ }
+
+ /// Absent fields say so rather than vanishing, so two logs stay comparable field by field.
+ func testAbsentFieldsAreNamedNotOmitted() {
+ let s = line(flags: nil, svc: [], tx: nil, nameLen: nil)
+ XCTAssertTrue(s.contains("flags=none"))
+ XCTAssertTrue(s.contains("svc=none"))
+ XCTAssertTrue(s.contains("tx=none"))
+ XCTAssertTrue(s.contains("nameLen=none"))
+ }
+
+ /// Deterministic ordering, so two captures diff cleanly instead of by dictionary order.
+ func testOutputIsStableRegardlessOfInputOrder() {
+ let a = ScanAdvertisementSummary.line(flags: 6, serviceUuids: ["b", "a"],
+ serviceDataLengths: ["y": 1, "x": 2],
+ manufacturerDataLengths: [2: 1, 1: 2],
+ txPower: nil, localNameLength: 4, connectable: true)
+ let b = ScanAdvertisementSummary.line(flags: 6, serviceUuids: ["a", "b"],
+ serviceDataLengths: ["x": 2, "y": 1],
+ manufacturerDataLengths: [1: 2, 2: 1],
+ txPower: nil, localNameLength: 4, connectable: true)
+ XCTAssertEqual(a, b)
+ }
+
+ /// Connectability separates a pairing-ready strap from a beacon-only one.
+ func testConnectabilityIsReported() {
+ XCTAssertTrue(line(connectable: true).contains("connectable=true"))
+ XCTAssertTrue(line(connectable: false).contains("connectable=false"))
+ }
+
+ /// The cross-platform guarantee. CoreBluetooth reports an assigned 16-bit UUID as "180d" while
+ /// Android always expands it, so without canonicalisation the SAME strap would log two different
+ /// lines and an iOS capture could not be diffed against an Android one. Both spellings must collapse
+ /// to one string — this is the assertion the Kotlin twin makes verbatim.
+ func testShortAndLongUuidSpellingsProduceTheSameLine() {
+ let short = line(svc: ["180d"], svcData: ["fd4b": 4])
+ let long = line(svc: ["0000180d-0000-1000-8000-00805f9b34fb"],
+ svcData: ["0000fd4b-0000-1000-8000-00805f9b34fb": 4])
+ XCTAssertEqual(short, long)
+ XCTAssertTrue(short.contains("svc=0000180d-0000-1000-8000-00805f9b34fb"))
+ }
+
+ /// A 32-bit assigned UUID takes the same base, and a 128-bit one is passed through untouched.
+ func testCanonicalisationCoversThirtyTwoBitAndLeavesFullUuidsAlone() {
+ XCTAssertEqual(ScanAdvertisementSummary.canonicalUuid("0000180d"),
+ "0000180d-0000-1000-8000-00805f9b34fb")
+ let full = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6"
+ XCTAssertEqual(ScanAdvertisementSummary.canonicalUuid(full), full)
+ }
+
+ /// The other cross-platform trap in this line, and the reason it is measured in bytes.
+ ///
+ /// Swift's `String.count` would say 7 for this name and Java's `String.length` would say 8, so the
+ /// same strap logged a different size on each platform. The Kotlin twin asserts the SAME 10.
+ func testLocalNameLengthIsUtf8BytesNotCharacters() {
+ XCTAssertEqual(ScanAdvertisementSummary.localNameLength("Whoop \u{1F389}"), 10)
+ XCTAssertEqual("Whoop \u{1F389}".count, 7, "guards the premise: characters would disagree")
+ XCTAssertEqual(ScanAdvertisementSummary.localNameLength("Ryan's Whoop"), 12)
+ XCTAssertNil(ScanAdvertisementSummary.localNameLength(nil))
+ }
+
+}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRContactTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRContactTests.swift
new file mode 100644
index 0000000000..d73a64f99a
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StandardHRContactTests.swift
@@ -0,0 +1,22 @@
+import XCTest
+@testable import WhoopProtocol
+
+/// The BLE Heart Rate Measurement flags decode must live on the protocol type so Swift and Kotlin
+/// stay aligned: bit 2 = supported, bit 1 = detected, unsupported whenever bit 2 is clear.
+final class StandardHRContactTests: XCTestCase {
+ func testFromMeasurementFlagsCoversAllCombinations() {
+ let cases: [(flags: UInt8, expected: StandardHRContact)] = [
+ (0x00, .unsupported),
+ (0x02, .unsupported),
+ (0x04, .supportedNotDetected),
+ (0x06, .supportedDetected),
+ ]
+ for test in cases {
+ XCTAssertEqual(
+ StandardHRContact.fromMeasurementFlags(test.flags),
+ test.expected,
+ "flags 0x\(String(test.flags, radix: 16))"
+ )
+ }
+ }
+}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift
index 588069002b..97d932921c 100644
--- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StreamsTests.swift
@@ -73,4 +73,58 @@ final class StreamsTests: XCTestCase {
deviceClockRef: deviceClockRef, wallClockRef: wallClockRef)
XCTAssertEqual(s.hr.count, 1)
}
+
+ // MARK: - the extraction gate reads the FULL verdict
+ //
+ // Scenario "Zustandstreibende Tore fordern das volle Urteil / Strom- und Verlaufs-Extraktion
+ // überspringt ungültige Rahmen". Each frame is a real REALTIME_DATA broken in exactly one way and
+ // still fully decodable, so what is missing from the result is attributable to the gate.
+
+ func testFrameWithABrokenHeaderChecksumYieldsNoRow() {
+ var broken = bytes(rt0)
+ broken[3] ^= 0xFF // header checksum only
+ let p = parseFrame(broken)
+ XCTAssertEqual(p.crcOK, true, "precondition: the payload CRC32 still verifies")
+ XCTAssertNotNil(p.parsed["heart_rate"], "precondition: the frame still decodes an HR")
+ let s = extractStreams([p], deviceClockRef: deviceClockRef, wallClockRef: wallClockRef)
+ XCTAssertTrue(s.hr.isEmpty, "no row may be derived from a frame that is not intact")
+ XCTAssertTrue(s.rr.isEmpty)
+ }
+
+ func testFrameWithTrailingBytesYieldsNoRow() {
+ let p = parseFrame(bytes(rt0) + [0x00])
+ XCTAssertEqual(p.rejectReason, .lengthMismatch)
+ let s = extractStreams([p], deviceClockRef: deviceClockRef, wallClockRef: wallClockRef)
+ XCTAssertTrue(s.hr.isEmpty)
+ }
+
+ func testOneBrokenFrameInASequenceLosesOnlyItsOwnRow() {
+ var broken = bytes(rt1)
+ broken[3] ^= 0xFF
+ let s = extractStreams([parseFrame(bytes(rt0)), parseFrame(broken)],
+ deviceClockRef: deviceClockRef, wallClockRef: wallClockRef)
+ XCTAssertEqual(s.hr, [HRSample(ts: 1_736_365_593, bpm: 60)],
+ "the intact frame still produces its row; only the broken one is skipped")
+ }
+
+ // MARK: - D7: a named field is never read out of the CRC32 trailer (post-hook half, 2.18)
+
+ /// This 17-byte WHOOP 4.0 REALTIME_DATA frame is INTACT — correct header checksum, correct CRC32,
+ /// exact declared length of 13 — but its inner record ends at byte 13, so the R-R count the
+ /// realtime post-hook reads at offset 13 is the first byte of the frame's own CRC32 trailer. That
+ /// byte is 0x01, and the two trailer bytes behind it read as a 33403 ms interval: a physiologically
+ /// impossible R-R that the strap never sent and that nothing downstream could tell from a real one.
+ ///
+ /// The gate cannot catch this — the frame passes every checksum. Only the payload bound does.
+ func testRealtimeRRCountIsNotReadOutOfTheTrailer() {
+ let frame = bytes("aa0d00e9280100000009000000017b826e")
+ let p = parseFrame(frame)
+ XCTAssertTrue(p.ok, "precondition: this frame is intact by every checksum")
+ XCTAssertEqual(p.typeName, "REALTIME_DATA")
+ XCTAssertEqual(frame[13], 0x01, "precondition: the trailer's first byte reads as rr_count = 1")
+ XCTAssertEqual(p.parsed["rr_intervals"], .intArray([]),
+ "an R-R interval must never be decoded out of the CRC32 trailer")
+ let s = extractStreams([p], deviceClockRef: deviceClockRef, wallClockRef: wallClockRef)
+ XCTAssertTrue(s.rr.isEmpty, "…and no such row may reach the datastore")
+ }
}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop4HelloSerialTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop4HelloSerialTests.swift
new file mode 100644
index 0000000000..7122ab0fad
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop4HelloSerialTests.swift
@@ -0,0 +1,92 @@
+import Foundation
+import XCTest
+@testable import WhoopProtocol
+
+/// The 4.0 serial decode (#1193) — twin of the Kotlin `Whoop4HelloSerialTest`, same cases, same values.
+final class Whoop4HelloSerialTests: XCTestCase {
+
+ /// A 131-byte cmd-35 response shaped like the captured one: a 9-char serial at 14, and the 54-char
+ /// DEVICE KEY at 24 that must stay unreachable.
+ private func capturedShape(serial: String = "WBB5AP053",
+ key: String = String(repeating: "K", count: 54)) -> [UInt8] {
+ var p = [UInt8](repeating: 0, count: 131)
+ for (i, c) in serial.utf8.enumerated() { p[14 + i] = c }
+ for (i, c) in key.utf8.enumerated() { p[24 + i] = c }
+ return p
+ }
+
+ func testReadsTheSerialFromTheCapturedShape() {
+ XCTAssertEqual(Whoop4HelloSerial.decode(payload: capturedShape()), "WBB5AP053")
+ }
+
+ /// The headline safety property: the decoder reads a fixed 9-byte window, so the device key beside
+ /// the serial cannot be returned no matter what it contains.
+ func testNeverReturnsTheDeviceKey() {
+ let out = Whoop4HelloSerial.decode(payload: capturedShape())
+ XCTAssertEqual(out?.count, 9)
+ XCTAssertFalse(out?.contains("K") ?? true)
+ }
+
+ /// A window that is not a clean alnum run is refused rather than guessed at — a moved field must
+ /// cost a strap its stable id, never migrate its history onto junk.
+ func testRefusesANonAlphanumericWindow() {
+ var p = capturedShape()
+ p[18] = 0x00
+ XCTAssertNil(Whoop4HelloSerial.decode(payload: p))
+ p = capturedShape()
+ p[14] = 0x2D // '-' is legal in an id but is NOT alnum, so this window is not a serial run
+ XCTAssertNil(Whoop4HelloSerial.decode(payload: p))
+ }
+
+ func testRefusesAShortPayload() {
+ XCTAssertNil(Whoop4HelloSerial.decode(payload: [UInt8](repeating: 0x41, count: 22)))
+ XCTAssertNil(Whoop4HelloSerial.decode(payload: []))
+ }
+
+ /// Exactly long enough is enough — the window ends at 23, so a 23-byte payload decodes.
+ func testAcceptsTheMinimumLength() {
+ var p = [UInt8](repeating: 0x41, count: 23)
+ for (i, c) in "ABC123XYZ".utf8.enumerated() { p[14 + i] = c }
+ XCTAssertEqual(Whoop4HelloSerial.decode(payload: p), "ABC123XYZ")
+ }
+}
+
+/// The two-sighting gate (#1193) — twin of the Kotlin `RepeatedSerialGateTest`.
+final class RepeatedSerialGateTests: XCTestCase {
+
+ /// The point of the gate: one sighting is never enough to act on.
+ func testWithholdsUntilTheSameValueRepeats() {
+ var g = RepeatedSerialGate()
+ XCTAssertNil(g.offer("WBB5AP053"))
+ XCTAssertEqual(g.offer("WBB5AP053"), "WBB5AP053")
+ }
+
+ /// The failure this exists to prevent: a per-session value never confirms, so it can never migrate
+ /// a history onto a fresh id per connect.
+ func testAValueThatKeepsChangingNeverConfirms() {
+ var g = RepeatedSerialGate()
+ for s in ["AAA111AAA", "BBB222BBB", "CCC333CCC", "DDD444DDD"] {
+ XCTAssertNil(g.offer(s), "a changing value must never confirm")
+ }
+ }
+
+ /// Confirms once, not on every later sighting — the caller adopts on a non-nil return, and adopting
+ /// repeatedly would redo the migration on every connect.
+ func testConfirmsExactlyOnce() {
+ var g = RepeatedSerialGate()
+ _ = g.offer("WBB5AP053")
+ XCTAssertEqual(g.offer("WBB5AP053"), "WBB5AP053")
+ XCTAssertNil(g.offer("WBB5AP053"))
+ XCTAssertNil(g.offer("WBB5AP053"))
+ }
+
+ /// An interruption resets the run: two sightings must be consecutive, so alternating values cannot
+ /// accumulate their way to a false confirmation.
+ func testAlternatingValuesDoNotConfirm() {
+ var g = RepeatedSerialGate()
+ XCTAssertNil(g.offer("AAA111AAA"))
+ XCTAssertNil(g.offer("BBB222BBB"))
+ XCTAssertNil(g.offer("AAA111AAA"))
+ XCTAssertEqual(g.offer("AAA111AAA"), "AAA111AAA")
+ }
+}
diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift
new file mode 100644
index 0000000000..55ff493be8
--- /dev/null
+++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgRawRecordTests.swift
@@ -0,0 +1,82 @@
+import XCTest
+@testable import WhoopProtocol
+
+/// #891/#1100: the type-43 REALTIME_RAW_DATA sample decoder, Swift side.
+///
+/// Twin of the Kotlin `Whoop5EcgRawRecordTest`. The offsets are a protocol fact, so both platforms carry the
+/// decode and both pin it — the parity contract is that these two cannot drift, and a test only on the side
+/// that happens to call it today would not stop Swift drifting.
+final class Whoop5EcgRawRecordTests: XCTestCase {
+
+ /// A 240-byte record with `samples` written i16-LE from the waveform offset.
+ private func rawRecord(samples: [Int] = [], type: UInt8 = Whoop5Ecg.rawRecordType) -> [UInt8] {
+ var f = [UInt8](repeating: 0, count: Whoop5Ecg.rawRecordLength)
+ f[Whoop5Ecg.rawTypeOffset] = type
+ var i = Whoop5Ecg.rawWaveformStart
+ for v in samples {
+ if i + 1 >= Whoop5Ecg.rawBodyEnd { break }
+ let u = UInt16(bitPattern: Int16(truncatingIfNeeded: v))
+ f[i] = UInt8(u & 0xFF)
+ f[i + 1] = UInt8((u >> 8) & 0xFF)
+ i += 2
+ }
+ return f
+ }
+
+ func testTheRecordCarriesExactly101Samples() {
+ // Load-bearing beyond arithmetic: this is the lag the BPM estimator has to refuse.
+ XCTAssertEqual(Whoop5Ecg.samplesPerRawRecord, 101)
+ XCTAssertEqual(Whoop5Ecg.realtimeRawSamples(rawRecord())?.count, 101)
+ }
+
+ func testOnlyA240ByteType43FrameIsARawRecord() {
+ XCTAssertTrue(Whoop5Ecg.isRealtimeRawRecord(rawRecord()))
+ XCTAssertFalse(Whoop5Ecg.isRealtimeRawRecord(rawRecord(type: 0x2F)))
+ XCTAssertFalse(Whoop5Ecg.isRealtimeRawRecord(Array(rawRecord().prefix(200))))
+ XCTAssertNil(Whoop5Ecg.realtimeRawSamples(rawRecord(type: 0x2F)))
+ XCTAssertNil(Whoop5Ecg.realtimeRawBodyNonZeroBytes([0, 0, 0, 0]))
+ XCTAssertNil(Whoop5Ecg.realtimeRawSignalPresent([]))
+ }
+
+ func testSamplesAreSignedLittleEndian() {
+ let injected = [0, 1, -1, 32767, -32768, 258, -258]
+ let got = Whoop5Ecg.realtimeRawSamples(rawRecord(samples: injected))!
+ XCTAssertEqual(Array(got.prefix(injected.count)), injected)
+ // Everything past what was written stays zero rather than being trimmed away.
+ XCTAssertEqual(got[injected.count], 0)
+ }
+
+ func testZeroSamplesAreKeptNotTrimmed() {
+ let got = Whoop5Ecg.realtimeRawSamples(rawRecord(samples: [50, 0, 0, 0]))!
+ XCTAssertEqual(got.count, 101)
+ XCTAssertEqual(got[0], 50)
+ XCTAssertTrue(got.dropFirst().allSatisfy { $0 == 0 })
+ }
+
+ func testSignalPresentIsOneRuleSharedByEveryConsumer() {
+ XCTAssertEqual(Whoop5Ecg.realtimeRawBodyNonZeroBytes(rawRecord()), 0)
+ XCTAssertEqual(Whoop5Ecg.realtimeRawSignalPresent(rawRecord()), false)
+
+ // Just AT the threshold is still not "present" — the rule is strictly greater.
+ var atThreshold = rawRecord()
+ for i in 0..> 24) & 0xFF)
return out
}
+
+ // MARK: - direct caller of the verifier (2.5): the useful path is unchanged
+
+ /// `Whoop5RawOptical.decode` calls `verifyFrame` directly. The real captured record must still
+ /// decode under the tightened verifier — the 2,140-byte buffer declares 2,132, so its total is
+ /// exact and it clears the structural rules — and the reason on the check must be `none`, not just
+ /// a positive verdict by accident.
+ func testTheRealRecordStillDecodesUnderTheTightenedVerifier() throws {
+ let oracle = try loadOracle()
+ let bytes = hexToBytes(oracle.records[0].hex)
+ let check = verifyFrame(bytes, family: .whoop5)
+ XCTAssertTrue(check.ok)
+ XCTAssertEqual(check.reason, .none)
+ XCTAssertNotNil(Whoop5RawOptical.decode(bytes), "the useful path must be untouched")
+ }
+
+ /// And the frame class the verifier newly rejects is refused before any sample is read.
+ func testARecordWithABrokenHeaderChecksumIsRefused() {
+ var frame = Self.sealedSyntheticFrame()
+ XCTAssertNotNil(Whoop5RawOptical.decode(frame), "control: the sealed frame decodes")
+ frame[6] ^= 0xFF // header CRC16 only; the payload CRC32 stands
+ XCTAssertEqual(verifyFrame(frame, family: .whoop5).crc32OK, true)
+ XCTAssertEqual(verifyFrame(frame, family: .whoop5).reason, .headerChecksumMismatch)
+ XCTAssertNil(Whoop5RawOptical.decode(frame))
+ }
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift b/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift
index e71786af04..e4af279b5a 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/BackupSettings.swift
@@ -34,9 +34,8 @@ public enum BackupSettings {
/// (platform-neutral) names. Mirrored exactly by Android's `BackupSettingsCodec.WHITELIST`.
///
/// Profile: the body metrics that power HR zones / calories / recovery baselines, plus the manual
- /// HR-max override (`profile.hrMax`, 0 = auto/Tanaka). Display: the metric/imperial system, the
- /// separate temperature override ("" = match the system), and the Effort axis (#268) — the three
- /// display prefs that exist with identical semantics on both platforms. Deliberately EXCLUDED:
+ /// HR-max override (`profile.hrMax`, 0 = auto/Tanaka). Display: the body and exercise-distance
+ /// systems, separate temperature override ("" = follow body), and Effort axis (#268). Deliberately EXCLUDED:
/// step calibration (per-strap, not per-person), the avatar blob (bulky, and not "settings"),
/// steps-engine fitted outputs (derived), and every noop.* toggle that is device- or
/// install-specific — INCLUDING the Today/Sleep section order, Key-Metrics and dashboard-card
@@ -56,8 +55,10 @@ public enum BackupSettings {
"profile.hrMax": .int,
"profile.hrZoneThresholds": .string,
"units.system": .string,
+ "units.distance": .string,
"units.temperature": .string,
"effort.scale": .string,
+ "dayCycle.mode": .string,
"today.hostedCards": .string,
// #1361: the user's own custom journal BEHAVIOURS (newline-joined names). Deliberately NOT in
// `appleDefaultsKey` below — it isn't a flat UserDefaults key (customs are derived from the
@@ -80,8 +81,10 @@ public enum BackupSettings {
"profile.hrMax": "profile.hrMaxOverride",
"profile.hrZoneThresholds": "profile.hrZoneThresholds",
"units.system": "units.system",
+ "units.distance": "units.distance",
"units.temperature": "units.temperature",
"effort.scale": "effort.scale",
+ "dayCycle.mode": "noop.dayCycleMode",
"today.hostedCards": "today.hostedCards",
]
@@ -177,6 +180,11 @@ public enum BackupSettings {
}
private static func isBoolean(_ n: NSNumber) -> Bool {
+#if canImport(Darwin)
CFGetTypeID(n) == CFBooleanGetTypeID()
+#else
+ // corelibs Foundation boxes JSON booleans with the Objective-C `c` type encoding.
+ String(cString: n.objCType) == "c"
+#endif
}
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/ClockRef.swift b/Packages/WhoopStore/Sources/WhoopStore/ClockRef.swift
new file mode 100644
index 0000000000..4553bb7a10
--- /dev/null
+++ b/Packages/WhoopStore/Sources/WhoopStore/ClockRef.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+/// The strap-clock ↔ wall-clock correlation captured at the start of a session.
+///
+/// Declared here rather than beside the raw outbox because it has nothing to do with it. This is the
+/// type the LIVE and BACKFILL paths carry — `Collector`, `Backfiller`, `ClockCorrelation` and
+/// `BLEManager` all hold one, and every historical decode is offset by it. It reached the raw outbox
+/// only because `RawBatchMeta` stores the same pair alongside a batch.
+///
+/// It previously lived inside `RawOutbox.swift`, which is wrapped whole in `#if canImport(Compression)`
+/// so the zlib-backed outbox compiles only on Apple platforms. That guard took this type with it, so on
+/// a platform without Compression the entire clock-correlation vocabulary vanished from the module's
+/// public API — a file-level guard reaching well past the surface it was meant to gate.
+public struct ClockRef: Equatable, Codable {
+ public let device: Int
+ public let wall: Int
+ public init(device: Int, wall: Int) { self.device = device; self.wall = wall }
+}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/CoachMessageStore.swift b/Packages/WhoopStore/Sources/WhoopStore/CoachMessageStore.swift
new file mode 100644
index 0000000000..f057566eaa
--- /dev/null
+++ b/Packages/WhoopStore/Sources/WhoopStore/CoachMessageStore.swift
@@ -0,0 +1,73 @@
+import Foundation
+import GRDB
+
+// MARK: - v39 store: persisted Coach conversation (PRD-K2)
+//
+// CoachMessageStore.swift — GRDB CRUD over the `coachMessage` table (migration v39), so the AI
+// Coach's chat survives relaunch. Mirrors the established `LabMarkerStore` idiom: a plain Codable
+// row struct, raw `Row` fetch + manual decode, all GRDB work via the actor's `syncWrite`/`syncRead`.
+//
+// Stores only the coach's text replies + the user's questions — no raw biometric readings were ever
+// in the chat (only the derived summary text sent with the request), so this carries the same
+// no-raw-egress posture as the network call itself. NOT part of the `.noopbak` backup whitelist.
+
+/// One persisted turn in the Coach conversation.
+public struct CoachMessageRow: Equatable, Codable, Sendable {
+ public var id: String
+ public var role: String // "user" | "assistant"
+ public var text: String
+ public var provider: String
+ public var createdAt: Int // epoch seconds
+ public var orderIndex: Int // monotonic replay order (two streamed turns can share createdAt)
+
+ public init(id: String, role: String, text: String, provider: String, createdAt: Int, orderIndex: Int) {
+ self.id = id
+ self.role = role
+ self.text = text
+ self.provider = provider
+ self.createdAt = createdAt
+ self.orderIndex = orderIndex
+ }
+
+ static func decode(_ row: Row) -> CoachMessageRow {
+ CoachMessageRow(
+ id: row["id"], role: row["role"], text: row["text"], provider: row["provider"],
+ createdAt: row["createdAt"], orderIndex: row["orderIndex"]
+ )
+ }
+}
+
+extension WhoopStore {
+
+ /// The full stored conversation, oldest first (by `orderIndex`).
+ public func coachMessages() async throws -> [CoachMessageRow] {
+ try syncRead { db in
+ try Row.fetchAll(db, sql: "SELECT * FROM coachMessage ORDER BY orderIndex ASC")
+ .map(CoachMessageRow.decode)
+ }
+ }
+
+ /// Replace the ENTIRE stored conversation with `rows` in one transaction. Simpler and safer than
+ /// incremental insert/update/delete bookkeeping across the engine's several streaming-finalization
+ /// call sites (a streamed reply mutates the same message's text repeatedly before settling); the
+ /// table is capped at the caller's `maxStoredMessages`, so a full replace is always cheap. Called
+ /// once per completed send/brief, never per streamed chunk.
+ public func replaceCoachMessages(_ rows: [CoachMessageRow]) async throws {
+ try syncWrite { db in
+ try db.execute(sql: "DELETE FROM coachMessage")
+ for row in rows {
+ try db.execute(sql: """
+ INSERT INTO coachMessage (id, role, text, provider, createdAt, orderIndex)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, arguments: [row.id, row.role, row.text, row.provider, row.createdAt, row.orderIndex])
+ }
+ }
+ }
+
+ /// Wipe the entire stored conversation (the Coach toolbar's "Clear conversation" action).
+ public func clearCoachMessages() async throws {
+ try syncWrite { db in
+ try db.execute(sql: "DELETE FROM coachMessage")
+ }
+ }
+}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift
index 88f8ead082..21a049c2d6 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift
@@ -538,12 +538,20 @@ extension WhoopStore {
// That keeps a v26-heavy night to roughly the same order of magnitude as ONE extra per-second
// stream (≈50 bytes/row), not 24x that. Additive only, a NEW table, no existing row touched.
//
- // Retention: no pruning, matching every other durable per-second table (hrSample, spo2Sample, …
- // are never pruned either) — this is decoded biometric history, not the transient raw outbox.
- // `PrunePolicy`'s ~50 MB cap governs ONLY `rawBatch` (raw, pre-decode frames kept for re-decode /
- // re-sync); it is untouched by and unrelated to this table. Growth here is bounded by how much
- // v26 data a strap actually emits (firmware chooses v26 vs v18 per second, not every night is
- // v26-heavy), not by an artificial cap.
+ // Retention: CAPPED at `WhoopStore.ppgWaveformRetentionRows` newest rows per device (#1911), swept
+ // amortised on insert exactly like `v18AuxSample`. This table is the ONE exception to "no durable
+ // per-second table is pruned" (hrSample, spo2Sample, … are still never pruned), because it is the
+ // only one storing a blob rather than a scalar: ~120 B/row against ~30 B. It is the worst ROW, not
+ // the biggest table — v26 runs only in optical windows (~28,800 rows/day) where `rrInterval` banks
+ // ~100,000/day, so most of #1911's ~93 MB/day is still unbounded elsewhere. `PrunePolicy`'s
+ // ~50 MB cap governs ONLY `rawBatch` (raw, pre-decode frames kept for re-decode / re-sync); it is
+ // untouched by and unrelated to this table.
+ //
+ // The cap is NEWEST-N-ROWS, not an age-based drop, and that distinction is load-bearing for the
+ // consumer note below: a sporadic wearer's v26 seconds are spread thin over months, so deleting by
+ // wall-clock age would empty the table for exactly the user a future estimator needs most, while
+ // newest-N always leaves a full working set. See `ppgWaveformRetentionRows` for the byte maths and
+ // for why #1911's "diagnostic-only, drop after the hot window" framing was not followed.
//
// CONSUMER STATUS — deliberately none, and stated here so nobody has to re-derive it. The writer is
// live on both platforms (offload + archive replay + the Android capture importer), but the reader
@@ -572,16 +580,8 @@ extension WhoopStore {
// the six wire columns (ax…az,gx…gz). Twin of the Android `rawImuSample` table (MIGRATION_20_21);
// same column order + PK so a `.noopbak` round-trips byte-for-byte.
//
- // CONSUMER STATUS — deliberately none on this platform, in the same shape as the `ppgWaveformSample`
- // (v27) and `v18AuxSample` (v31) notes. The writer (`Collector.storeRawImu`) is instrument-first: it
- // fires only with raw capture enabled AND a 5/MG deep-data unlock, and is bounded to
- // `WhoopStore.rawImuRetentionRows` (3600 one-second buffers, a rolling window — not a corpus). No
- // analytic, score, gate, UI or export reads a row: the only SQL is the retention DELETE (`StreamStore`)
- // plus a COUNT in the storage-stats readout (`LocalAccessCore`); `ImuFeatureExtractor` takes the
- // protocol struct, never this table, so it is not a consumer either. Swift has NO reader yet, on
- // purpose — a reader lands WITH a validated consumer, not before ("artifact, not one match", CLAUDE.md).
- // Android keeps a dormant `rawImuSamples` reader (zero callers) for the eventual cross-check; do NOT
- // "clean up" either side as dead code — the retained rows are the deliverable. Audit: #978.
+ // Historical rolling cache. Its opt-in writer retained at most 3,600 one-second rows, but no analytics,
+ // UI or export consumed them. v41 retires those legacy rows after file-backed capture replaces the cache.
migrator.registerMigration("v28-raw-imu") { db in
try db.create(table: "rawImuSample") { t in
t.column("deviceId", .text).notNull()
@@ -887,6 +887,38 @@ extension WhoopStore {
t.add(column: "skinTempC", .double)
}
}
+ // Retire the bounded, write-only legacy cache. Session-owned IMU now lives in the file-backed store.
+ migrator.registerMigration("v41-drop-raw-imu-sample") { db in
+ try db.drop(table: "rawImuSample")
+ }
+ // Whether every sleep session that day was staged from heart rate alone (#1801). The Kotlin twin
+ // is `DailyMetric.sleepHrOnly`, added by Room MIGRATION_35_36; the shared schema oracle pins the
+ // two shapes together, so this exists here even while only Android reads it — a column present on
+ // one side and absent on the other is the drift #775 tracks, and stagingSparse set the precedent
+ // for carrying a staging-quality flag on both.
+ migrator.registerMigration("v42-daily-sleep-hr-only") { db in
+ try db.alter(table: "dailyMetric") { t in
+ t.add(column: "sleepHrOnly", .boolean)
+ }
+ }
+ // PRD-K2: persist the Coach conversation on-device so it survives relaunch. One row per chat
+ // turn; `orderIndex` is a monotonically-increasing counter (not `createdAt`, which two
+ // streamed turns can share to the second) so replay order is exact. `provider` isn't filtered
+ // on for v1 (a conversation is a conversation across a provider switch) but is carried so a
+ // future per-provider view/filter doesn't need another migration. Never added to the
+ // `.noopbak` backup whitelist (a separate, deliberate decision — CLAUDE.md's backup contract).
+ migrator.registerMigration("v43-coach-messages") { db in
+ try db.create(table: "coachMessage", options: [.ifNotExists]) { t in
+ t.column("id", .text).primaryKey()
+ t.column("role", .text).notNull() // "user" | "assistant"
+ t.column("text", .text).notNull()
+ t.column("provider", .text).notNull()
+ t.column("createdAt", .integer).notNull()
+ t.column("orderIndex", .integer).notNull()
+ }
+ // No index: the table is capped at maxStoredMessages (40 rows), so a full scan + sort on
+ // read is negligible and an index buys nothing worth the extra Room<->GRDB parity surface.
+ }
return migrator
}
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift
index 7788dc3642..1c38d097a3 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift
@@ -108,6 +108,11 @@ public struct DeviceRegistryStore: Sendable {
}
}
+ /// Suffix of the COMPUTED sibling every device id owns — the engine writes its derived days, detected
+ /// workouts and metric series under `-noop`. Spelled here so the adoption can carry it with
+ /// the pairing; the Kotlin twin is `WhoopRepository.computedDeviceId`.
+ public static let computedSuffix = "-noop"
+
/// Every table whose rows are keyed by `deviceId` (the per-device sample/derived tables). This is
/// the authoritative list `deleteAllData` clears — kept in sync with the `deviceId`-keyed tables in
/// `Database.swift`. The `pairedDevice` registry row itself is NOT here (a delete-data operation
@@ -134,7 +139,6 @@ public struct DeviceRegistryStore: Sendable {
"ppgWaveformSample",
// v28-raw-imu (#423): the opt-in 5/MG raw-IMU offload capture is deviceId-keyed too — "delete all
// of this device's data" must clear it, or the raw inertial samples survive deletion (same defect).
- "rawImuSample",
// v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second
// rows like every stream above, so a "delete all of this device's data" must clear them too.
"v18AuxSample",
@@ -199,9 +203,19 @@ public struct DeviceRegistryStore: Sendable {
""", arguments: [serialId, activeId])
}
// Move the active id's recordings onto the serial id (canonical wins a PK clash), then clear it.
- for table in Self.deviceScopedTables {
- try db.execute(sql: "UPDATE OR IGNORE \(table) SET deviceId = ? WHERE deviceId = ?", arguments: [serialId, activeId])
- try db.execute(sql: "DELETE FROM \(table) WHERE deviceId = ?", arguments: [activeId])
+ //
+ // BOTH id shapes, not just the pairing's own. Every strap also owns a COMPUTED sibling keyed
+ // `-noop` — the scored days, detected workouts and metric series the engine derives
+ // — and that id is never equal to `activeId`, so an exact-match re-key silently left it behind.
+ // The next scoring pass then writes under `-noop`, stranding the old computed
+ // history under an id nothing reads again: the orphaned-history failure this adoption exists
+ // to PREVENT, displaced onto the computed half. The ring path never hit it because a ring has
+ // no computed sibling to strand, which is why the shipped migration looked proven.
+ for (from, to) in [(activeId, serialId), (activeId + Self.computedSuffix, serialId + Self.computedSuffix)] {
+ for table in Self.deviceScopedTables {
+ try db.execute(sql: "UPDATE OR IGNORE \(table) SET deviceId = ? WHERE deviceId = ?", arguments: [to, from])
+ try db.execute(sql: "DELETE FROM \(table) WHERE deviceId = ?", arguments: [from])
+ }
}
// Drop ONLY the provisional CB-UUID registry rows; other oura-* pairings are left as-is.
try db.execute(sql: "DELETE FROM pairedDevice WHERE id = ?", arguments: [activeId])
diff --git a/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift b/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift
new file mode 100644
index 0000000000..d955bf361e
--- /dev/null
+++ b/Packages/WhoopStore/Sources/WhoopStore/HypnogramCoverage.swift
@@ -0,0 +1,153 @@
+import Foundation
+
+/// How much of a sleep session's `[startTs, endTs)` span its stage segments actually account for.
+///
+/// WHY THIS EXISTS. A session's stage timeline is supposed to TILE its span — `SleepStageTotals`
+/// says so explicitly ("the segment stages noop stores ... TILE the window ... Σ stage minutes equals
+/// the clock span"), and every consumer is written as though it does. One producer breaks that
+/// invariant: a device-PROVIDED hypnogram assembled from records that arrived INCOMPLETE. The Oura
+/// path (`OuraSleepSessionMapping`) merges only CONTIGUOUS codes, so a sleep-phase page that never
+/// arrived leaves a hole in `stagesJSON` while `startTs`/`endTs` still span the whole night. The
+/// result is a session that looks well-formed — non-empty, many segments, a plausible efficiency —
+/// but describes a fraction of the night it claims.
+///
+/// Measured on 31 consecutive ring nights, 8 of them came in under 95% and one covered 23% of its
+/// own span (601 min claimed, 140 min of segments). Downstream that night was stored as 70 minutes of
+/// sleep against a paired strap's 494, and nothing flagged it: the merge's richness rule tests only
+/// that stages are PRESENT, and Rest's two confidence guards are `gravitySparse` (inert here — Oura
+/// stores no gravity at all, so `isGravitySparse` returns false for every ring night) and the #H9
+/// restorative floor (needs efficiency ≥ 0.85; the holed night read 0.50).
+///
+/// So the missing quantity is not a new measurement — it is a RATIO of two numbers already stored.
+/// Nothing here is persisted and no migration is needed: coverage is derived on read, which also means
+/// it applies retroactively to nights already in the database.
+///
+/// HONEST-DATA: this only ever reports how much of the night was OBSERVED. It never fills a hole in,
+/// and in particular it must not let a caller treat unobserved time as awake — we do not know what
+/// happened there, and asserting wake would be the same overreach in the opposite direction.
+///
+/// PARITY: pure + deterministic, byte-identical to the Kotlin twin
+/// (`com.noop.analytics.HypnogramCoverage`). Keep the two in lockstep.
+public enum HypnogramCoverage {
+
+ /// Coverage at or above which a stage timeline is treated as describing its whole span.
+ ///
+ /// The observed split is wide: healthy nights land at 99–100%, the broken ones at 23–93%. 0.95
+ /// sits in the empty middle, so the gate is not balanced on the edge of the data.
+ public static let minCoverage: Double = 0.95
+
+ /// The covered fraction of `spanSeconds`, or nil when the question does not apply.
+ ///
+ /// Returns nil — meaning "unknown, do not judge" — rather than 0 when there is nothing to measure,
+ /// so an unknown coverage can never be mistaken for a bad one by a caller that compares against
+ /// `minCoverage`. Clamped to at most 1: segments that overlap or overhang would otherwise report
+ /// more than a full night, and for a completeness gate the safe direction is to read that as
+ /// "complete" rather than to invent a failure out of malformed input.
+ public static func fraction(coveredSeconds: Double, spanSeconds: Double) -> Double? {
+ guard spanSeconds > 0, coveredSeconds > 0 else { return nil }
+ return min(1.0, coveredSeconds / spanSeconds)
+ }
+
+ /// The covered fraction of `spanSeconds` for a session's stored `stagesJSON`, or nil when coverage
+ /// is not a meaningful question for that payload.
+ ///
+ /// nil for: a nil/blank/`"[]"` payload (no stages at all — that is the richness question, not this
+ /// one), for the IMPORTED minute-dict shape `{light,deep,rem,awake}` and for Health Connect's
+ /// `{stage,min}` array, neither of which carries timestamps to compare against a span, and for an
+ /// array holding any non-object element (see the Kotlin twin's loop for why that bails rather than
+ /// measuring the remainder).
+ ///
+ /// SCOPE, precisely. Timestamp-free shapes are what keep this gate off the WHOOP CSV, Apple and
+ /// Health Connect imports — they are never judged incomplete, so their behaviour is unchanged. That
+ /// is NOT a blanket exemption for imports, and it was originally written as one: the Xiaomi Band
+ /// importer emits real `{start,end,stage}` segments, and takes its span from `bedtime`/`wake_up_time`
+ /// fields that are independent of the `items` array it builds those segments from. A Xiaomi night
+ /// whose items do not reach its own bed/wake bounds IS judged holed here. That is arguably the right
+ /// answer — the night genuinely is only partly described — but it is a real behaviour change for
+ /// that importer, unvalidated against a Xiaomi export, and it is pinned by test rather than left to
+ /// be discovered.
+ public static func fraction(stagesJSON: String?, spanSeconds: Double) -> Double? {
+ fraction(coveredSeconds: coveredSeconds(stagesJSON: stagesJSON), spanSeconds: spanSeconds)
+ }
+
+ /// Seconds of `stagesJSON` accounted for by timestamped segments, or 0 when the payload carries no
+ /// measurable ones.
+ ///
+ /// Zero, not nil, because this is a SUMMABLE quantity: a caller accumulating several fragments needs
+ /// "this one contributed nothing" to add cleanly, and the "is coverage even a meaningful question"
+ /// decision belongs to `fraction`, which turns a zero total back into nil. Callers that want the
+ /// unknown/known distinction for a SINGLE session must therefore keep using `fraction`, not this.
+ ///
+ /// Returns 0 for every shape `fraction(stagesJSON:spanSeconds:)` documents as unmeasurable — a
+ /// nil/blank/`"[]"` payload, the imported minute-dict `{light,deep,rem,awake}`, Health Connect's
+ /// `{stage,min}` array, and an array holding any non-object element (the whole payload bails rather
+ /// than measuring the remainder). Splitting it out is behaviour-preserving for that function: a 0
+ /// total fails `fraction`'s `coveredSeconds > 0` guard and still yields nil.
+ public static func coveredSeconds(stagesJSON: String?) -> Double {
+ guard let json = stagesJSON?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !json.isEmpty, json != "[]",
+ let data = json.data(using: .utf8),
+ let segs = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
+ else { return 0 }
+ var covered = 0.0
+ for seg in segs {
+ guard let s = (seg["start"] as? NSNumber)?.doubleValue,
+ let e = (seg["end"] as? NSNumber)?.doubleValue, e > s else { continue }
+ covered += e - s
+ }
+ return covered
+ }
+
+ /// Coverage summed across a bridged main-night GROUP — the quantity a night is actually judged on.
+ ///
+ /// A night is not one row. `AnalyticsEngine.analyzeDay` bridges the day's main-sleep fragments into a
+ /// group (#561) and accumulates coverage over ALL of them before testing the gate, so a per-row answer
+ /// is the wrong question for anything presenting a night: two fragments that are individually 90% and
+ /// 99% covered are one night at neither figure. This exists so a UI can ask the same question the
+ /// engine asked without restating the accumulation, which is where a twin rule would drift.
+ ///
+ /// PARITY WITH THE ENGINE, exactly. Every fragment contributes its full `[startTs, endTs)` to the
+ /// denominator whether or not its payload is measurable, and contributes cover only from timestamped
+ /// segments — which reproduces `analyzeDay`'s decoded-segment accumulation term for term, including
+ /// the mixed-source case it calls out (a timestamp-free fragment adds span and no cover, so a group
+ /// mixing one with a timestamped fragment reads as holed). A group whose fragments are ALL
+ /// timestamp-free totals 0 covered seconds and comes back nil — unknown, not holed.
+ /// The accumulation itself, over `(payload, span)` fragments. This is the shape the Kotlin twin
+ /// implements — entity-free, because `HypnogramCoverage` sits in `com.noop.analytics` over there and
+ /// must not reach into `com.noop.data` for a row type. The session overload below is a thin adapter,
+ /// so both platforms run the identical arithmetic and only the packaging differs.
+ public static func groupFraction(_ fragments: [(stagesJSON: String?, spanSeconds: Double)]) -> Double? {
+ var covered = 0.0, span = 0.0
+ for f in fragments {
+ span += f.spanSeconds
+ covered += coveredSeconds(stagesJSON: f.stagesJSON)
+ }
+ return fraction(coveredSeconds: covered, spanSeconds: span)
+ }
+
+ public static func groupFraction(_ sessions: [CachedSleepSession]) -> Double? {
+ groupFraction(sessions.map { (stagesJSON: $0.stagesJSON,
+ spanSeconds: Double($0.endTs - $0.startTs)) })
+ }
+
+ /// `isHoled` for a bridged main-night GROUP. Unknown coverage (nil) is NOT holed — same fail-open
+ /// contract as every other guard here.
+ public static func isHoledGroup(_ sessions: [CachedSleepSession]) -> Bool {
+ guard let f = groupFraction(sessions) else { return false }
+ return f < minCoverage
+ }
+
+ /// True when the timeline is known to cover less than `minCoverage` of its span — i.e. the session
+ /// demonstrably describes only part of the night it claims. Unknown coverage (nil) is NOT holed:
+ /// every guard built on this fails OPEN, so a payload shape this cannot measure keeps its existing
+ /// behaviour instead of being silently downgraded.
+ public static func isHoled(stagesJSON: String?, spanSeconds: Double) -> Bool {
+ guard let f = fraction(stagesJSON: stagesJSON, spanSeconds: spanSeconds) else { return false }
+ return f < minCoverage
+ }
+
+ /// `isHoled` for a stored session, using its own `[startTs, endTs)` as the span.
+ public static func isHoled(_ s: CachedSleepSession) -> Bool {
+ isHoled(stagesJSON: s.stagesJSON, spanSeconds: Double(s.endTs - s.startTs))
+ }
+}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift b/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift
index c3ed5fecc4..f519632d38 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift
@@ -105,13 +105,16 @@ public struct DailyMetric: Equatable, Codable {
/// that column and `SkinTempDisplay.isAbsoluteSkinTemp` separates them by magnitude. This column is
/// unambiguous: it is always an absolute, and only the strap pipeline writes it.
public let skinTempC: Double?
+ /// Kotlin twin: `DailyMetric.sleepHrOnly`. Every session that night staged from heart rate alone.
+ public let sleepHrOnly: Bool?
public init(day: String, totalSleepMin: Double?, efficiency: Double?, deepMin: Double?,
remMin: Double?, lightMin: Double?, disturbances: Int?, restingHr: Int?,
avgHrv: Double?, recovery: Double?, strain: Double?, exerciseCount: Int?,
spo2Pct: Double? = nil, skinTempDevC: Double? = nil, respRateBpm: Double? = nil,
steps: Int? = nil, activeKcalEst: Double? = nil,
spo2Red: Int? = nil, spo2Ir: Int? = nil, avgSdnn: Double? = nil,
- skinTempC: Double? = nil) {
+ skinTempC: Double? = nil,
+ sleepHrOnly: Bool? = nil) {
self.day = day; self.totalSleepMin = totalSleepMin; self.efficiency = efficiency
self.deepMin = deepMin; self.remMin = remMin; self.lightMin = lightMin
self.disturbances = disturbances; self.restingHr = restingHr; self.avgHrv = avgHrv
@@ -120,6 +123,7 @@ public struct DailyMetric: Equatable, Codable {
self.steps = steps; self.activeKcalEst = activeKcalEst
self.spo2Red = spo2Red; self.spo2Ir = spo2Ir; self.avgSdnn = avgSdnn
self.skinTempC = skinTempC
+ self.sleepHrOnly = sleepHrOnly
}
/// The freshest STRICTLY-PRIOR day that carries at least one overnight vital (HRV / resting HR /
@@ -152,6 +156,41 @@ public struct DailyMetric: Equatable, Codable {
public nonisolated static func lastSkinTempDay(days: [DailyMetric], todayKey: String) -> DailyMetric? {
days.last(where: { $0.skinTempDevC != nil && $0.day < todayKey })
}
+
+ /// The freshest strictly-prior row carrying EITHER skin-temp number (#1844), so a surface can lead
+ /// with the absolute and fall back to the deviation from ONE night rather than mixing two.
+ ///
+ /// The OR here is deliberate and is NOT the #1842 defect. That bug read field X off a row selected on
+ /// (X or Y), so a row holding only Y blanked X. This selects a row for a value that is "whichever of
+ /// the two this night has", and the caller reads both fields off THAT row and lets
+ /// `SkinTempDisplay.leadReading` pick — so the chosen row always supplies the number shown, and an
+ /// absolute is never paired with another night's deviation. `lastSkinTempDay` stays as-is for the
+ /// deviation-only surfaces. Byte-twin of the Android `lastSkinTempReadingRow`.
+ public nonisolated static func lastSkinTempReadingDay(days: [DailyMetric], todayKey: String) -> DailyMetric? {
+ days.last(where: { ($0.skinTempC != nil || $0.skinTempDevC != nil) && $0.day < todayKey })
+ }
+
+ /// PER-FIELD HRV carry — the twin of `lastSpo2Day` for a field `lastVitalsDay` DOES check, which is
+ /// precisely why it needs one. That predicate is an OR across HRV / resting-HR / respiratory, so it
+ /// resolves the freshest row carrying ANY of the three — including a respiratory-only row whose
+ /// `avgHrv` is nil. The HRV card then reads that nil and renders "—" while a tile carrying a different
+ /// row shows a real number on the same screen (#1842). Resolving per field picks the freshest row that
+ /// actually holds an HRV, the same correction `lastSpo2Day` and `lastSkinTempDay` already make.
+ ///
+ /// Deliberately NOT staleness-bounded, unlike `Repository.lastRespDay`: an old HRV/RHR carry is
+ /// disclosed rather than suppressed — the call sites stamp the row's own date and `carriedCaption`
+ /// relabels a weeks-old one to "Latest sleep" (#779). Respiratory took a hard bound instead because a
+ /// single CSV import's value was reading as today's for a fortnight (#1331). Same `$0.day < todayKey`
+ /// future-clock guard; `days` is oldest→newest. Byte-twin of the Android `lastHrvRow`.
+ public nonisolated static func lastHrvDay(days: [DailyMetric], todayKey: String) -> DailyMetric? {
+ days.last(where: { $0.avgHrv != nil && $0.day < todayKey })
+ }
+
+ /// PER-FIELD resting-HR carry — twin of `lastHrvDay`, same OR-predicate cause and same unbounded-by-
+ /// design carry (#1842). Byte-twin of the Android `lastRestingHrRow`.
+ public nonisolated static func lastRestingHrDay(days: [DailyMetric], todayKey: String) -> DailyMetric? {
+ days.last(where: { $0.restingHr != nil && $0.day < todayKey })
+ }
}
extension WhoopStore {
@@ -159,11 +198,29 @@ extension WhoopStore {
// MARK: - Upserts (idempotent by natural key; latest server value wins on conflict)
/// Upsert cached sleep sessions. Natural key (deviceId, startTs). Returns rows changed.
+ ///
+ /// A non-user-edited candidate whose stage timeline is LESS complete than what's already stored for
+ /// the same (deviceId, startTs) is dropped whole rather than written: a device can serve the same
+ /// night's hypnogram more than once (a reconnect mid-day, a resumed drain), and a later, shorter
+ /// decode has no business overwriting an earlier, fuller one — the two would otherwise silently
+ /// disagree on `stagesJSON` vs. `efficiency`/`endTs` for the same row. `HypnogramCoverage`'s 0/1/2
+ /// richness rank (none / holed / covers-its-span) is the same scale `SleepMerge` already judges a
+ /// day's best session on for DISPLAY; this applies the identical comparison at WRITE time so it
+ /// protects every caller (BLE live sources, importers, recomputes), not just the ones a caller
+ /// happens to gate behind their own dedup toggle.
@discardableResult
public func upsertSleepSessions(_ sessions: [CachedSleepSession], deviceId: String) async throws -> Int {
try syncWrite { db in
var n = 0
for s in sessions {
+ if !s.userEdited, let existing = try Self.storedSleepSession(db, deviceId: deviceId, startTs: s.startTs),
+ !existing.userEdited {
+ let candidate = CachedSleepSession(startTs: s.startTs, endTs: s.endTs, efficiency: nil,
+ restingHr: nil, avgHrv: nil, stagesJSON: s.stagesJSON)
+ if SleepMerge.richness(candidate) < SleepMerge.richness(existing) {
+ continue // a less-complete re-serve of this night must never clobber the stored one
+ }
+ }
try db.execute(sql: """
INSERT INTO sleepSession
(deviceId, startTs, endTs, efficiency, restingHr, avgHrv, stagesJSON,
@@ -192,6 +249,18 @@ extension WhoopStore {
}
}
+ /// The stored row at (deviceId, startTs), or nil when there is none yet — the completeness check
+ /// `upsertSleepSessions` runs before deciding whether a candidate may overwrite it. Only the fields
+ /// that check needs are read; `efficiency`/`restingHr`/`avgHrv` are left nil since richness never
+ /// looks at them.
+ private static func storedSleepSession(_ db: Database, deviceId: String, startTs: Int) throws -> CachedSleepSession? {
+ guard let row = try Row.fetchOne(db, sql: """
+ SELECT endTs, stagesJSON, userEdited FROM sleepSession WHERE deviceId = ? AND startTs = ?
+ """, arguments: [deviceId, startTs]) else { return nil }
+ return CachedSleepSession(startTs: startTs, endTs: row["endTs"], efficiency: nil, restingHr: nil,
+ avgHrv: nil, stagesJSON: row["stagesJSON"], userEdited: row["userEdited"])
+ }
+
/// Hand-correct a sleep session's bed (onset) and/or wake (end) time. Sets `userEdited = 1` so the
/// next recompute/import `upsertSleepSessions` preserves the corrected bounds instead of overwriting
/// them with the strap-detected values. Keyed by the stable detected natural key
@@ -430,8 +499,8 @@ extension WhoopStore {
(deviceId, day, totalSleepMin, efficiency, deepMin, remMin, lightMin,
disturbances, restingHr, avgHrv, recovery, strain, exerciseCount,
spo2Pct, skinTempDevC, respRateBpm, steps, activeKcalEst,
- spo2Red, spo2Ir, avgSdnn, skinTempC)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ spo2Red, spo2Ir, avgSdnn, skinTempC, sleepHrOnly)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(deviceId, day) DO UPDATE SET
totalSleepMin = excluded.totalSleepMin,
efficiency = excluded.efficiency,
@@ -452,13 +521,14 @@ extension WhoopStore {
spo2Red = excluded.spo2Red,
spo2Ir = excluded.spo2Ir,
avgSdnn = excluded.avgSdnn,
- skinTempC = excluded.skinTempC
+ skinTempC = excluded.skinTempC,
+ sleepHrOnly = excluded.sleepHrOnly
""", arguments: [deviceId, d.day, d.totalSleepMin, d.efficiency, d.deepMin,
d.remMin, d.lightMin, d.disturbances, d.restingHr, d.avgHrv,
d.recovery, d.strain, d.exerciseCount,
d.spo2Pct, d.skinTempDevC, d.respRateBpm,
d.steps, d.activeKcalEst,
- d.spo2Red, d.spo2Ir, d.avgSdnn, d.skinTempC])
+ d.spo2Red, d.spo2Ir, d.avgSdnn, d.skinTempC, d.sleepHrOnly])
n += db.changesCount
}
return n
@@ -481,6 +551,23 @@ extension WhoopStore {
}
}
+ /// #1853: FILL-ONLY write of `skinTempC` for nights the backfill re-derived an absolute for.
+ /// Updates ONLY the `skinTempC` column of an existing row, and ONLY when that column is currently
+ /// NULL — never an upsert of a rebuilt row, never overwriting a measured value. A single-column
+ /// UPDATE is deliberate: the backfill must not touch `skinTempDevC` or any other scored field, and
+ /// a full-row upsert would risk clobbering a concurrent scoring pass's write. Returns rows filled.
+ @discardableResult
+ public func fillSkinTempC(deviceId: String, day: String, skinTempC: Double) async throws -> Int {
+ try syncWrite { db in
+ try db.execute(sql: """
+ UPDATE dailyMetric
+ SET skinTempC = ?
+ WHERE deviceId = ? AND day = ? AND skinTempC IS NULL
+ """, arguments: [skinTempC, deviceId, day])
+ return db.changesCount
+ }
+ }
+
// MARK: - Reads
/// Cached sleep sessions overlapping [from, to] (by startTs), oldest first.
@@ -509,7 +596,7 @@ extension WhoopStore {
SELECT day, totalSleepMin, efficiency, deepMin, remMin, lightMin, disturbances,
restingHr, avgHrv, recovery, strain, exerciseCount,
spo2Pct, skinTempDevC, respRateBpm, steps, activeKcalEst,
- spo2Red, spo2Ir, avgSdnn, skinTempC FROM dailyMetric
+ spo2Red, spo2Ir, avgSdnn, skinTempC, sleepHrOnly FROM dailyMetric
WHERE deviceId = ? AND day >= ? AND day <= ?
ORDER BY day ASC
""", arguments: [deviceId, from, to])
@@ -524,7 +611,8 @@ extension WhoopStore {
respRateBpm: $0["respRateBpm"],
steps: $0["steps"], activeKcalEst: $0["activeKcalEst"],
spo2Red: $0["spo2Red"], spo2Ir: $0["spo2Ir"], avgSdnn: $0["avgSdnn"],
- skinTempC: $0["skinTempC"])
+ skinTempC: $0["skinTempC"],
+ sleepHrOnly: $0["sleepHrOnly"])
}
}
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift
index a08a53a9aa..c717bac61e 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift
@@ -1,14 +1,9 @@
+#if canImport(Compression)
import Foundation
import Compression
import GRDB
import WhoopProtocol
-public struct ClockRef: Equatable, Codable {
- public let device: Int
- public let wall: Int
- public init(device: Int, wall: Int) { self.device = device; self.wall = wall }
-}
-
public struct RawBatchMeta: Equatable {
public let batchId: String
public let deviceId: String
@@ -252,3 +247,4 @@ extension WhoopStore {
}
}
}
+#endif
diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift
index 73a3971387..143e6e5ec6 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift
@@ -60,6 +60,23 @@ extension WhoopStore {
}
}
+ /// Standard-BLE sensor-contact readings recorded alongside generic HR samples. Rows before this
+ /// feature have no contact event, so this intentionally returns no invented legacy values.
+ public func standardHRContacts(deviceId: String, from: Int, to: Int,
+ limit: Int) async throws -> [StandardHRContactSample] {
+ try syncRead { db in
+ try Row.fetchAll(db, sql: """
+ SELECT ts, payloadJSON FROM event
+ WHERE deviceId = ? AND kind = ? AND ts >= ? AND ts <= ?
+ ORDER BY ts ASC LIMIT ?
+ """, arguments: [deviceId, StandardHRMapping.contactEventKind, from, to, limit])
+ .map { row in
+ let json: String = row["payloadJSON"]
+ return try StandardHRMapping.contactSample(ts: row["ts"], payloadJSON: json)
+ }
+ }
+ }
+
/// Cheap change-detector for the raw HR stream: `(count, maxTs)` over `[from, to]`, computed in
/// SQLite over the `(deviceId, ts)` index WITHOUT materializing any rows (#836). Lets a caller decide
/// "nothing was inserted since last time, skip the expensive re-read" for pennies, `COUNT(*)` moves on
@@ -99,6 +116,106 @@ extension WhoopStore {
}
}
+ /// Cross-device change detector for every raw stream that can change a daily score or sleep session.
+ ///
+ /// The original analysis watermark covered `hrSample` only. Historical offloads do not commit their
+ /// streams atomically: HR can land first and motion/gravity later. A pass run between those commits sees
+ /// no usable sleep, then the post-offload gate used to skip the decisive retry because HR itself had not
+ /// changed. Fingerprint the complete scoring input instead. HR keeps its established count+timestamp
+ /// fingerprint; the other streams use SQLite's monotonic rowid frontier, which catches old backfills
+ /// without full-table COUNT scans over millions of dense motion/R-R rows.
+ /// `v2` intentionally changes the persisted watermark once on upgrade so every install gets one clean
+ /// rescore under the complete contract.
+ public func analysisFingerprint() async throws -> String {
+ try syncRead { db in
+ guard let row = try Row.fetchOne(db, sql: """
+ SELECT
+ (SELECT COUNT(*) FROM hrSample) AS hc,
+ (SELECT COALESCE(MAX(ts), 0) FROM hrSample) AS hm,
+ (SELECT COALESCE(MAX(rowid), 0) FROM ppgHrSample) AS p,
+ (SELECT COALESCE(MAX(rowid), 0) FROM rrInterval) AS r,
+ (SELECT COALESCE(MAX(rowid), 0) FROM respSample) AS x,
+ (SELECT COALESCE(MAX(rowid), 0) FROM gravitySample) AS g,
+ (SELECT COALESCE(MAX(rowid), 0) FROM sleepStateSample) AS s,
+ (SELECT COALESCE(MAX(rowid), 0) FROM event) AS e,
+ (SELECT COALESCE(MAX(rowid), 0) FROM spo2Sample) AS o,
+ (SELECT COALESCE(MAX(rowid), 0) FROM skinTempSample) AS t,
+ (SELECT COALESCE(MAX(rowid), 0) FROM stepSample) AS z
+ """) else { return "" }
+ let hc: Int = row["hc"], hm: Int = row["hm"]
+ let keys = ["p", "r", "x", "g", "s", "e", "o", "t", "z"]
+ let tails = keys.map { key -> String in
+ let frontier: Int = row[key]
+ return key + String(frontier)
+ }
+ return "v2|h\(hc):\(hm)|" + tails.joined(separator: "|")
+ }
+ }
+
+ /// Per-day, per-owner change detector for every scored stream that [hrFingerprint] does NOT witness.
+ ///
+ /// [analysisFingerprint] answers "did anything change anywhere"; this answers "did THIS night's scored
+ /// input change", which is the question the `analyzeRecent` per-day reuse cache asks. A history offload
+ /// does not commit its channels together — HR can land first and R-R, respiration or SpO2 minutes later,
+ /// and an offloaded HR row duplicating a live one is dropped by `ON CONFLICT DO NOTHING` — so a night
+ /// scored once from HR alone can gain its R-R with `(count, maxTs)` over `hrSample` completely unmoved
+ /// (#29). Keyed on HR alone the cache re-served that HRV-less scan for the rest of the session.
+ ///
+ /// One statement, one index range walk per stream over the same `(deviceId, ts)` primary keys
+ /// [hrFingerprint] uses, materialising no rows. `COUNT(*)` is the load-bearing half: it moves when a
+ /// backfill lands rows INSIDE a window already covered, which `MAX(ts)` alone would miss.
+ ///
+ /// The streams are exactly the ones the per-day loop reads and hands to `analyzeDay`:
+ /// - `ppgHrSample`: the day's HR read is measured ∪ PPG-derived ([hrSamples]), so a PPG row for a second
+ /// with no measured HR changes the scored series while `hrSample` stays put.
+ /// - `rrInterval`: filtered exactly as [rrIntervals] filters at read (the 0x6E SpO2-IBI duplicate and
+ /// future-stamped beats are excluded), so the witness counts the beats that are actually scored.
+ /// - `sleepStateSample`: appended from the SAME v18 record at the same `ts` as HR, so a re-offloaded
+ /// record whose HR row is dropped on conflict still lands a new band row that `analyzeDay` scores.
+ /// Its letter is `b` (band), not `s` — `s1|` is the version prefix.
+ /// - `respSample`, `spo2Sample`, `gravitySample`, `stepSample`, `skinTempSample`, `event`.
+ ///
+ /// Returned as an opaque string: it is only ever compared to itself in memory, so no cross-platform or
+ /// cross-launch byte identity is required. The Kotlin twin is `WhoopDao.dayStreamFingerprint`.
+ public func dayStreamFingerprint(deviceId: String, from: Int, to: Int) async throws -> String {
+ try syncRead { db in
+ // Every sub-select is COUNT/COALESCE(MAX(...), 0), so each column is non-null and the aggregate
+ // query always returns exactly one row — same idiom as `analysisFingerprint` above.
+ guard let row = try Row.fetchOne(db, sql: """
+ SELECT
+ (SELECT COUNT(*) FROM ppgHrSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS pc,
+ (SELECT COALESCE(MAX(ts), 0) FROM ppgHrSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS pm,
+ (SELECT COUNT(*) FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t
+ AND (srcChannel IS NULL OR srcChannel <> :rrx)
+ AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS rc,
+ (SELECT COALESCE(MAX(ts), 0) FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t
+ AND (srcChannel IS NULL OR srcChannel <> :rrx)
+ AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS rm,
+ (SELECT COUNT(*) FROM respSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS xc,
+ (SELECT COALESCE(MAX(ts), 0) FROM respSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS xm,
+ (SELECT COUNT(*) FROM spo2Sample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS oc,
+ (SELECT COALESCE(MAX(ts), 0) FROM spo2Sample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS om,
+ (SELECT COUNT(*) FROM gravitySample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS gc,
+ (SELECT COALESCE(MAX(ts), 0) FROM gravitySample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS gm,
+ (SELECT COUNT(*) FROM stepSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS zc,
+ (SELECT COALESCE(MAX(ts), 0) FROM stepSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS zm,
+ (SELECT COUNT(*) FROM skinTempSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS tc,
+ (SELECT COALESCE(MAX(ts), 0) FROM skinTempSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS tm,
+ (SELECT COUNT(*) FROM sleepStateSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS bc,
+ (SELECT COALESCE(MAX(ts), 0) FROM sleepStateSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS bm,
+ (SELECT COUNT(*) FROM event WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS ec,
+ (SELECT COALESCE(MAX(ts), 0) FROM event WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS em
+ """, arguments: ["d": deviceId, "f": from, "t": to,
+ "rrx": RRSourceChannel.spo2Ibi.rawValue]) else { return "" }
+ let keys = ["p", "r", "x", "o", "g", "z", "t", "b", "e"]
+ let parts = keys.map { key -> String in
+ let count: Int = row[key + "c"], maxTs: Int = row[key + "m"]
+ return "\(key)\(count):\(maxTs)"
+ }
+ return "s1|" + parts.joined(separator: "|")
+ }
+ }
+
/// Aggregate HR over a window: `(n, avg, max)` computed in SQLite over the same measured-∪-PPG rows
/// [hrSamples] returns, WITHOUT materialising them and WITHOUT a row limit.
///
@@ -216,7 +333,7 @@ extension WhoopStore {
public func rrIntervals(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RRInterval] {
try syncRead { db in
try Row.fetchAll(db, sql: """
- SELECT ts, rrMs, srcChannel, ord FROM rrInterval
+ SELECT ts, rrMs, srcChannel, ord, seq FROM rrInterval
WHERE deviceId = ? AND ts >= ? AND ts <= ?
AND (srcChannel IS NULL OR srcChannel <> ?)
AND (tsSuspect IS NULL OR tsSuspect <> 1) -- #1073: exclude future-stamped beats
@@ -225,7 +342,7 @@ extension WhoopStore {
.map { row in
RRInterval(ts: row["ts"], rrMs: row["rrMs"],
srcChannel: (row["srcChannel"] as Int?).flatMap(RRSourceChannel.init(rawValue:)),
- ord: row["ord"] as Int?)
+ ord: row["ord"] as Int?, seq: row["seq"])
}
}
}
@@ -297,6 +414,75 @@ extension WhoopStore {
}
}
+ /// Kotlin twin: `WhoopDao.stepSamplesPage`.
+ public func stepSamplesPage(deviceId: String, afterExclusive: Int, endExclusive: Int,
+ limit: Int) async throws -> [StepSample] {
+ try syncRead { db in
+ try Row.fetchAll(db, sql: """
+ SELECT ts, counter, activityClass FROM stepSample
+ WHERE deviceId = ? AND ts > ? AND ts < ?
+ ORDER BY ts ASC LIMIT ?
+ """, arguments: [deviceId, afterExclusive, endExclusive, limit]).map {
+ StepSample(ts: $0["ts"], counter: $0["counter"], activityClass: $0["activityClass"])
+ }
+ }
+ }
+
+ /// Last counter sample before a cycle boundary, used to attribute the first in-cycle delta correctly.
+ /// Kotlin twin: `WhoopDao.stepSampleBefore`.
+ public func stepSampleBefore(deviceId: String, before: Int) async throws -> StepSample? {
+ try syncRead { db in
+ try Row.fetchOne(db, sql: """
+ SELECT ts, counter, activityClass FROM stepSample
+ WHERE deviceId = ? AND ts < ? ORDER BY ts DESC LIMIT 1
+ """, arguments: [deviceId, before]).map {
+ StepSample(ts: $0["ts"], counter: $0["counter"], activityClass: $0["activityClass"])
+ }
+ }
+ }
+
+ /// Kotlin twin: `WhoopDao.hasStepActivityClasses`.
+ public func hasStepActivityClasses(deviceId: String, from: Int, to: Int) async throws -> Bool {
+ try syncRead { db in
+ try Int.fetchOne(db, sql: """
+ SELECT EXISTS(SELECT 1 FROM stepSample
+ WHERE deviceId = ? AND ts >= ? AND ts < ? AND activityClass IS NOT NULL)
+ """, arguments: [deviceId, from, to]) == 1
+ }
+ }
+
+ /// Kotlin twin: `WhoopRepository.stepTimestampCoverage`.
+ public func stepTimestampCoverage(deviceId: String, from: Int, to: Int) async throws
+ -> (first: Int?, last: Int?) {
+ try syncRead { db in
+ let row = try Row.fetchOne(db, sql: """
+ SELECT MIN(ts) AS firstTs, MAX(ts) AS lastTs FROM stepSample
+ WHERE deviceId = ? AND ts >= ? AND ts < ?
+ """, arguments: [deviceId, from, to])
+ return (row?["firstTs"], row?["lastTs"])
+ }
+ }
+
+ /// Bounded process-local invalidator over the UTC days touched by this window. The instance token
+ /// prevents a static cycle cache surviving a store reopen from reusing revisions from the old DB.
+ /// Kotlin twin: `WhoopRepository.stepDataRevisionSignature`.
+ public func stepDataRevisionSignature(deviceId: String, from: Int, to: Int) async -> String {
+ "\(revisionInstanceToken):\(deviceId):\(stepDataRevision.signature(deviceId: deviceId, from: from, to: to))"
+ }
+
+ public func stepDiagnosticMotionCounts(deviceId: String, from: Int, to: Int) async throws
+ -> (gravity: Int, aux: Int) {
+ try syncRead { db in
+ let gravity = try Int.fetchOne(db, sql: """
+ SELECT COUNT(*) FROM gravitySample WHERE deviceId = ? AND ts >= ? AND ts < ?
+ """, arguments: [deviceId, from, to]) ?? 0
+ let aux = try Int.fetchOne(db, sql: """
+ SELECT COUNT(*) FROM v18AuxSample WHERE deviceId = ? AND ts >= ? AND ts < ?
+ """, arguments: [deviceId, from, to]) ?? 0
+ return (gravity, aux)
+ }
+ }
+
public func respSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RespSample] {
try syncRead { db in
try Row.fetchAll(db, sql: """
@@ -323,6 +509,41 @@ extension WhoopStore {
/// is an index-ONLY scan (both columns live in that index, so no table rows are touched) with
/// `deviceId` leading, which also lets the GROUP BY skip a temp b-tree. Cheap enough for a
/// user-triggered diagnostics export, which is the only caller.
+ /// Which raw streams a device has ANY rows for in a window — the strap-capability question,
+ /// answered without counting.
+ ///
+ /// EXISTS rather than COUNT on purpose. The question is presence, every one of these tables is keyed
+ /// `(deviceId, ts)`, so each subquery is an index seek that stops at the first row instead of walking a
+ /// night's worth — a 4.0 night carries ~190k gravity rows and counting them to learn "yes" would be
+ /// absurd. One statement so it is one round trip.
+ ///
+ /// Answers what no existing line could: a strap streaming live HR with no motion cannot produce a
+ /// staged night or an auto-detected workout, and until now a reader had to infer that from the absence
+ /// of other lines. Diagnostics-export only, like `rawSampleCountsByDevice`. Kotlin twin:
+ /// `WhoopDao.streamPresence`.
+ public struct StreamPresence: Sendable, Equatable {
+ public let hr: Bool, rr: Bool, gravity: Bool, steps: Bool
+ public init(hr: Bool, rr: Bool, gravity: Bool, steps: Bool) {
+ self.hr = hr; self.rr = rr; self.gravity = gravity; self.steps = steps
+ }
+ }
+
+ public func streamPresence(deviceId: String, from: Int, to: Int) async throws -> StreamPresence {
+ try syncRead { db in
+ let row = try Row.fetchOne(db, sql: """
+ SELECT
+ EXISTS(SELECT 1 FROM hrSample WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS hr,
+ EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS rr,
+ EXISTS(SELECT 1 FROM gravitySample WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS gravity,
+ EXISTS(SELECT 1 FROM stepSample WHERE deviceId = ? AND ts >= ? AND ts <= ?) AS steps
+ """, arguments: [deviceId, from, to, deviceId, from, to,
+ deviceId, from, to, deviceId, from, to])
+ return StreamPresence(
+ hr: (row?["hr"] ?? 0) != 0, rr: (row?["rr"] ?? 0) != 0,
+ gravity: (row?["gravity"] ?? 0) != 0, steps: (row?["steps"] ?? 0) != 0)
+ }
+ }
+
public func rawSampleCountsByDevice(from: Int, to: Int) async throws -> [(String, Int)] {
try syncRead { db in
try Row.fetchAll(db, sql: """
@@ -384,28 +605,132 @@ extension WhoopStore {
}
}
+ /// Per-table row counts for every accumulating decoded stream, keyed identically to Android's
+ /// `WhoopRepository.storageRowCounts` so a maintainer reads the SAME map from either platform.
+ ///
+ /// #1911 wants to know WHICH table holds a multi-gigabyte database, and the answer was unavailable on
+ /// Apple: the Test Centre probe emitted nine keys and left out `ppgHr`, `sleepState`, `ppgWaveform`
+ /// and `v18Aux` — the four Android's own comment singles out as "can each be large", and the ones a
+ /// row-count model misprices worst, since `ppgWaveformSample` is the only blob table here. A footprint
+ /// that omits the blob table cannot attribute the bytes it was collected to explain.
+ ///
+ /// Shares `rawTableKeys` with `storageStats()` and `TimestampHeal` so a table added to one is seen by
+ /// all three — counted in the total, named in the breakdown, and healed. Best-effort
+ /// per table: an unreadable count is omitted rather than reported as zero, because a zero here reads
+ /// as "this table is empty" and that is a different claim from "this table could not be read".
+ public func storageRowCounts() async throws -> [String: Int] {
+ try syncRead { db in
+ var out: [String: Int] = [:]
+ for (key, table) in Self.rawTableKeys {
+ if let n = try? Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table)") { out[key] = n }
+ }
+ return out
+ }
+ }
+
+ /// Estimated payload bytes per decoded stream table, keyed as `storageRowCounts()` keys.
+ ///
+ /// #1911 asks which table holds a multi-gigabyte database, and row counts alone cannot answer it: the
+ /// twelve fixed-width tables are comparable by rows, but `ppgWaveformSample` stores a BLOB whose size
+ /// varies, and it is the table most likely to hold bytes a per-second row model does not predict. So
+ /// this measures the blob rather than assuming it.
+ ///
+ /// Sampled, not scanned: the mean payload of up to `sampleRows` rows, multiplied by the count. A full
+ /// `SUM(LENGTH(...))` over a 6.5 GB store is exactly the kind of read this diagnostic exists to let
+ /// someone avoid, and an estimate answers "which table dominates" just as well.
+ ///
+ /// What it measures: BLOB and TEXT columns by their real byte/character length, numeric columns at a
+ /// nominal 8. It EXCLUDES index pages, page slack and the WAL, so it is a payload lower bound, not the
+ /// file size — compare it against `db_bytes` in the same probe rather than expecting the two to meet.
+ /// An unreadable table is omitted, on the same reasoning as the row counts: absent is not zero.
+ /// `rowCounts` lets a caller that already has them - the Test Centre probe does - avoid a second
+ /// `COUNT(*)` pass over every table. That matters here more than it usually would: this diagnostic is
+ /// read on the multi-gigabyte stores it exists to explain, where each count is a full scan, so a tool
+ /// that counts the same thirteen tables three times is slowest exactly where it is needed.
+ public func storageByteEstimates(sampleRows: Int = 500,
+ rowCounts: [String: Int]? = nil) async throws -> [String: Int] {
+ try syncRead { db in
+ var out: [String: Int] = [:]
+ for (key, table) in Self.rawTableKeys {
+ let known = rowCounts?[key]
+ guard let rows = known ?? (try? Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(table)")),
+ rows > 0
+ else { continue }
+ let cols = try? Row.fetchAll(db, sql: "PRAGMA table_info(\(table))")
+ guard let cols, !cols.isEmpty else { continue }
+ // Table and column names come from the schema, never from user input, so interpolating
+ // them is safe here in the same way the COUNT above is.
+ // `typeof()` at runtime, not the DECLARED type, and byte length via CAST rather than
+ // LENGTH: SQLite is dynamically typed, so a column declared INTEGER can hold text, and
+ // LENGTH on text counts CHARACTERS while the store spends bytes. Mirrors the expression
+ // Kotlin already uses in `PushSnapshotPreflight.rowEstimateExpression`.
+ let terms = cols.compactMap { c -> String? in
+ guard let name = c["name"] as String? else { return nil }
+ let q = "\"\(name)\""
+ return "(CASE WHEN \(q) IS NULL THEN 0 "
+ + "WHEN typeof(\(q)) IN ('text','blob') THEN length(CAST(\(q) AS BLOB)) "
+ + "ELSE 8 END)"
+ }
+ guard !terms.isEmpty else { continue }
+ let sql = "SELECT AVG(n) FROM (SELECT (\(terms.joined(separator: " + "))) AS n "
+ // Clamped: SQLite reads a NEGATIVE limit as no limit at all, so an unchecked value
+ // here would full-scan the very tables the sampling exists to avoid scanning.
+ + "FROM \(table) LIMIT \(max(1, sampleRows)))"
+ if let mean = try? Double.fetchOne(db, sql: sql), mean.isFinite, mean >= 0 {
+ out[key] = Int((mean * Double(rows)).rounded())
+ }
+ }
+ return out
+ }
+ }
+
+ /// The decoded stream tables and the key each reports under. The keys are Android's, verbatim.
+ static let rawTableKeys: [(String, String)] = [
+ ("hr", "hrSample"), ("rr", "rrInterval"), ("events", "event"), ("battery", "battery"),
+ ("spo2", "spo2Sample"), ("skinTemp", "skinTempSample"), ("resp", "respSample"),
+ ("gravity", "gravitySample"), ("steps", "stepSample"), ("ppgHr", "ppgHrSample"),
+ ("sleepState", "sleepStateSample"), ("ppgWaveform", "ppgWaveformSample"),
+ ("v18Aux", "v18AuxSample"),
+ ]
+
+ /// The raw outbox alone: batch count and total byteSize, with NO decoded-table counting.
+ ///
+ /// `storageStats()` returns these beside a 13-table `COUNT(*)` sweep, and the Test Centre probe was
+ /// calling it purely for `rawBytes` and discarding the rest - thirteen full scans, on the large stores
+ /// where a scan is least free, for two numbers that touch a different table entirely.
+ public func rawOutboxStats() async throws -> (batches: Int, bytes: Int) {
+ try syncRead { try Self.rawOutbox($0) }
+ }
+
+ /// The outbox pair against an OPEN database, so `storageStats()` and `rawOutboxStats()` share one
+ /// implementation. It has to take `db` rather than call the other: `syncRead` is `dbWriter.read`, and
+ /// a read nested inside a read deadlocks - so a plain helper is the only way these two stop being a
+ /// copy of each other, and a test pinning that a copy agrees is a weaker thing than not having one.
+ private static func rawOutbox(_ db: Database) throws -> (batches: Int, bytes: Int) {
+ let batches = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM rawBatch") ?? 0
+ let bytes = try Int.fetchOne(db, sql: "SELECT COALESCE(SUM(byteSize), 0) FROM rawBatch") ?? 0
+ return (batches, bytes)
+ }
+
/// Aggregate storage footprint: total decoded rows, raw batch count, total raw byteSize.
public func storageStats() async throws -> (decodedRows: Int, rawBatches: Int, rawBytes: Int) {
try syncRead { db in
- // The COMPLETE set of accumulating decoded raw streams — KEEP IN SYNC with
- // `TimestampHeal.rawTables` (its per-timestamp purge is the canonical list) and the Android
- // `WhoopRepository.storageRowCounts`. Summed by iterating the list rather than a hand-written
- // expression, because the old fixed sum silently under-reported: it omitted stepSample,
- // ppgHrSample, sleepStateSample, ppgWaveformSample, rawImuSample and v18AuxSample — and a 4.0
- // with PPG (ppgHrSample/ppgWaveformSample) or IMU capture (rawImuSample) banks millions of rows.
+ // The COMPLETE set of accumulating decoded raw streams, from `rawTableKeys` — no longer a
+ // "keep in sync with TimestampHeal" instruction, because that copy is gone and the heal now
+ // reads the same list. Android's `WhoopRepository.storageRowCounts` is the one that still has
+ // to be kept in step by hand; the note there says so.
+ //
+ // Summed by iterating the list rather than a hand-written expression, because the old fixed
+ // sum silently under-reported: it omitted stepSample, ppgHrSample, sleepStateSample,
+ // ppgWaveformSample, rawImuSample and v18AuxSample — and a 4.0 with PPG banks millions of
+ // rows. (rawImuSample has since been dropped outright, in `v41-drop-raw-imu-sample`.)
// Table names are compile-time constants (never user input), so the interpolation is safe.
- let rawTables = ["hrSample", "rrInterval", "event", "battery",
- "spo2Sample", "skinTempSample", "respSample", "gravitySample",
- "stepSample", "ppgHrSample", "sleepStateSample", "ppgWaveformSample",
- "rawImuSample", "v18AuxSample"]
var decoded = 0
- for t in rawTables {
+ for (_, t) in Self.rawTableKeys {
decoded += try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(t)") ?? 0
}
- let batches = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM rawBatch") ?? 0
- let bytes = try Int.fetchOne(db,
- sql: "SELECT COALESCE(SUM(byteSize), 0) FROM rawBatch") ?? 0
- return (decoded, batches, bytes)
+ let outbox = try Self.rawOutbox(db)
+ return (decoded, outbox.batches, outbox.bytes)
}
}
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift
index c246fc18bb..ce0431767c 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift
@@ -1,6 +1,12 @@
import Foundation
import GRDB
+public struct SourcedMetricPoint: Sendable {
+ public let deviceId: String
+ public let point: MetricPoint
+ public init(deviceId: String, point: MetricPoint) { self.deviceId = deviceId; self.point = point }
+}
+
/// Provenance for one persisted NOOP-computed score. `sourceId` normally names the input provider;
/// `vo2max_est` uses the estimator id because the method is the provenance users need for that series.
/// Natural key: (computed device namespace, day, metric key).
@@ -37,7 +43,10 @@ extension WhoopStore {
provenance: [ScoreInputProvenanceRow],
deviceId: String,
from: String,
- to: String
+ to: String,
+ replaceMetricKeys: [String] = [],
+ additionalMetricPoints: [SourcedMetricPoint] = [],
+ replaceMetricSourceIds: [String] = []
) async throws {
// #1196: an empty scoring pass must not destructively rewrite the window — with no daily rows to
// write, the provenance wide-delete below would blank the window's attribution while a degenerate
@@ -47,7 +56,16 @@ extension WhoopStore {
guard !dailyMetrics.isEmpty else { return }
try syncWrite { db in
_ = try Self.upsertDailyMetrics(dailyMetrics, deviceId: deviceId, in: db)
+ for source in Set(replaceMetricSourceIds + [deviceId]) {
+ for key in replaceMetricKeys {
+ try db.execute(sql: "DELETE FROM metricSeries WHERE deviceId = ? AND key = ? AND day >= ? AND day <= ?",
+ arguments: [source, key, from, to])
+ }
+ }
_ = try Self.upsertMetricSeries(metricPoints, deviceId: deviceId, in: db)
+ for group in Dictionary(grouping: additionalMetricPoints, by: \.deviceId) {
+ _ = try Self.upsertMetricSeries(group.value.map(\.point), deviceId: group.key, in: db)
+ }
// Weekly VO₂max provenance is owned by `persistMetricSeriesWithProvenance` below, not this
// daily scoring window. Preserve it or every normal 21-day re-score erases the prior two
diff --git a/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift b/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift
index ecbbcd8d5f..d55d851927 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/SleepMerge.swift
@@ -16,6 +16,23 @@ public enum SleepMerge {
/// where neither side does, keep the imported-over-computed rule unchanged. (Swift twin of the
/// Android HealthConnectImporter richness fix, ryanbr/noop#240.)
///
+ /// Richness is a RANK, not a yes/no. Presence alone was the original test, and it cannot see a
+ /// device-provided hypnogram assembled from records that arrived incomplete: such a row carries many
+ /// segments — so it passes any presence check — while covering a fraction of the span it claims
+ /// (measured: one ring night with 21 segments over 23% of its 601-minute span, stored as 70 minutes
+ /// of sleep against a paired strap's 494). So a day's best session ranks:
+ ///
+ /// 2 stages that cover the span they claim (`HypnogramCoverage`)
+ /// 1 stages present but HOLED — real, but describing only part of the night
+ /// 0 no stages at all
+ ///
+ /// and the computed day wins only when it OUT-RANKS the import. Collapsing 1 and 0 together gives
+ /// back the original presence rule exactly, so this is a strict generalisation: every case #240
+ /// decided, it still decides the same way. What changes is the pair the old rule could not express
+ /// — a holed import against a COMPLETE computed night, which used to go to the import and now goes
+ /// to the night that actually covers itself. A holed import still beats no stages at all: partial
+ /// data is better than none, it just no longer outranks a whole night.
+ ///
/// - Parameter endDay: maps a session to its canonical LOCAL end-day key (callers inject their
/// timezone-aware keyer so this stays pure and testable).
public static func merge(imported: [CachedSleepSession],
@@ -30,8 +47,7 @@ public enum SleepMerge {
out.reserveCapacity(imported.count + computed.count)
for (day, imp) in importedByDay {
if let comp = computedByDay[day],
- !imp.contains(where: hasStages),
- comp.contains(where: hasStages) {
+ dayRichness(comp) > dayRichness(imp) {
out.append(contentsOf: comp) // richer computed day survives a stage-less import
} else {
out.append(contentsOf: imp) // imported wins its day (unchanged rule)
@@ -48,4 +64,19 @@ public enum SleepMerge {
guard let json = s.stagesJSON?.trimmingCharacters(in: .whitespacesAndNewlines) else { return false }
return !json.isEmpty && json != "[]"
}
+
+ /// How much of a night this session's stages actually describe: 2 = covers its span, 1 = present but
+ /// holed, 0 = none. A timeline whose coverage cannot be measured (the imported minute-dict shape,
+ /// which has no timestamps) is never holed, so it ranks 2 — imports keep being judged on presence
+ /// exactly as they always were, and this gate cannot reach them.
+ static func richness(_ s: CachedSleepSession) -> Int {
+ guard hasStages(s) else { return 0 }
+ return HypnogramCoverage.isHoled(s) ? 1 : 2
+ }
+
+ /// A day's richness is that of its BEST session — a day with a fully-staged main night plus an
+ /// unstaged nap is a staged day (#715 keeps every session of the winning day either way).
+ static func dayRichness(_ sessions: [CachedSleepSession]) -> Int {
+ sessions.reduce(0) { max($0, richness($1)) }
+ }
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/SourceIdentity.swift b/Packages/WhoopStore/Sources/WhoopStore/SourceIdentity.swift
new file mode 100644
index 0000000000..cf9a936602
--- /dev/null
+++ b/Packages/WhoopStore/Sources/WhoopStore/SourceIdentity.swift
@@ -0,0 +1,48 @@
+import Foundation
+
+/// Which registry device a live BLE link's samples belong to (#1881).
+///
+/// Split out of `BLEManager` because it is the whole of the decision and none of the CoreBluetooth: BLE
+/// behaviour cannot be tested in CI, but *this* can, and it is the part that silently files rows under the
+/// wrong device when it is wrong. The Kotlin twin is `com.noop.ble.SourceIdentity`.
+///
+/// The bug it exists to prevent: `BLEManager` held one `deviceId` meaning "the active device" and reused it
+/// as "the device these bytes came from". Those were the same thing only while every registered device was
+/// a WHOOP. Once a ring could be active, a WHOOP connection filed its samples — including `stepSample` and
+/// `gravitySample`, which only a strap can produce — under the ring's id.
+public enum SourceIdentity {
+
+ /// The device id this connection's samples should be stored under, or nil to leave the current id alone.
+ ///
+ /// Conservative by construction. It returns an id ONLY when a registry row is both matched by peripheral
+ /// and a WHOOP, so every uncertain case keeps today's behaviour rather than guessing:
+ /// • `address` nil, or no row carries it → nil. This is the legacy single-WHOOP row before it has
+ /// adopted a `peripheralId`; the coordinator adopts one on this same connect, so the next resolves.
+ /// • the matched row is not a WHOOP → nil. Nothing else should be arriving on the WHOOP delegate, and
+ /// if it does, re-pointing the strap's id at it would be the very bug this prevents.
+ /// • the matched row is already the current id → nil, so there is no write on the common path.
+ ///
+ /// Matching is case-insensitive because the two platforms disagree about case: Apple stores an uppercase
+ /// `CBPeripheral.identifier.uuidString`, Android an uppercase MAC — but neither is guaranteed by the OS,
+ /// and a row written by an older build may carry either.
+ public static func resolve(address: String?, rows: [PairedDevice], currentId: String) -> String? {
+ // BLANK, not merely empty, so the two platforms agree: Kotlin's `isNullOrBlank` refuses a
+ // whitespace-only address, and a bare `isEmpty` here would let one match a stored blank.
+ guard let address, !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+ guard let row = rows.first(where: {
+ guard let pid = $0.peripheralId else { return false }
+ return pid.caseInsensitiveCompare(address) == .orderedSame
+ }) else { return nil }
+ guard isWhoop(row), row.id != currentId else { return nil }
+ return row.id
+ }
+
+ /// A device is a WHOOP when it is the seeded legacy row or its brand says so. Kept here beside the
+ /// resolver rather than reaching into the app target, so this file has no dependency to test around.
+ /// Byte-identical to `SourceCoordinator.isWhoop` on both platforms.
+ public static func isWhoop(_ device: PairedDevice) -> Bool {
+ device.id == "my-whoop" || device.brand.caseInsensitiveCompare("WHOOP") == .orderedSame
+ }
+}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift b/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift
index 229d03ed57..287035c07a 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/StandardHRMapping.swift
@@ -8,15 +8,68 @@ import WhoopProtocol
/// can't be unit-tested.
///
/// A chest strap (Polar / Wahoo / Coospo / Garmin HRM / Amazfit Helio broadcast) only ever
-/// reports HR and (optionally) R-R intervals over 0x2A37; every other stream (spo2, skin temp,
-/// resp, gravity, steps, ppgHr, events, battery) is left empty.
+/// reports HR, (optionally) R-R intervals, and its standard contact flags over 0x2A37; every other
+/// stream (spo2, skin temp, resp, gravity, steps, ppgHr, battery) is left empty.
public enum StandardHRMapping {
+ /// `event.kind` used for a standard BLE sensor-contact reading. The generic event table is the
+ /// established durable stream for additive decoded signals, so this needs no schema migration.
+ public static let contactEventKind = "STANDARD_HR_CONTACT"
+
+ /// Should this reading record a contact event, given the last one recorded?
+ ///
+ /// Contact is a STATE, not a measurement: it changes when the strap goes on or comes off, a handful
+ /// of times a day, while the standard 0x2A37 stream arrives at ~1 Hz. Writing one row per reading
+ /// stored ~86,400 rows a day per device to say the same thing 86,390 times, into a local-first
+ /// database, for a read side that reconstructs "the value at or before ts" and therefore only ever
+ /// needed the changes.
+ ///
+ /// `previous == nil` records, so every session opens with its starting state and a reader never has
+ /// to assume one. Twin of Kotlin `StandardHrMapping.shouldRecordContact`.
+ public static func shouldRecordContact(previous: StandardHRContact?,
+ current: StandardHRContact) -> Bool {
+ previous != current
+ }
+
/// Build a `Streams` carrying one HR sample and zero-or-more R-R intervals, all stamped at the
/// same wall-clock `ts` (unix seconds). Pure → unit-testable.
- public static func samples(fromHR hr: Int, rr: [Int], at ts: Int) -> Streams {
- Streams(
+ public static func samples(fromHR hr: Int, rr: [Int], contact: StandardHRContact? = nil,
+ at ts: Int) -> Streams {
+ let events = contact.map { [
+ WhoopEvent(ts: ts, kind: contactEventKind, payload: ["contact": .string($0.rawValue)])
+ ] } ?? []
+ return Streams(
hr: [HRSample(ts: ts, bpm: hr)],
- rr: rr.map { RRInterval(ts: ts, rrMs: $0) }
+ rr: rr.map { RRInterval(ts: ts, rrMs: $0) },
+ events: events
)
}
+
+ /// Parse a persisted `STANDARD_HR_CONTACT` `payloadJSON`. Throws on malformed JSON or a missing /
+ /// unknown `contact` field so a parse failure is not the same as "no contact event".
+ public static func contactSample(ts: Int, payloadJSON: String) throws -> StandardHRContactSample {
+ let payload = try JSONDecoder().decode([String: ParsedValue].self, from: Data(payloadJSON.utf8))
+ guard case let .string(raw)? = payload["contact"] else {
+ throw ContactSampleError.missingContact
+ }
+ guard let contact = StandardHRContact(rawValue: raw) else {
+ throw ContactSampleError.unknownContact(raw)
+ }
+ return StandardHRContactSample(ts: ts, contact: contact)
+ }
+
+ public enum ContactSampleError: Error, Equatable {
+ case missingContact
+ case unknownContact(String)
+ }
+}
+
+/// One persisted standard-BLE sensor-contact reading. Legacy HR rows have no companion event and are
+/// therefore absent from this stream instead of being guessed as any contact state.
+public struct StandardHRContactSample: Equatable, Sendable {
+ public let ts: Int
+ public let contact: StandardHRContact
+ public init(ts: Int, contact: StandardHRContact) {
+ self.ts = ts
+ self.contact = contact
+ }
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
index 43bf0b7fe4..eacc9f463f 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
@@ -58,14 +58,56 @@ extension WhoopStore {
return out
}
- /// #423 rolling retention for the raw-IMU capture table (twin of Kotlin `RAW_IMU_RETENTION_ROWS`):
- /// ~1 h at 1 row/strap-second (~4 MB) hard-caps the table during a multi-day offload replay.
- public static let rawImuRetentionRows = 3600
+ /// Rolling retention for the v27 PPG waveform table (twin of Kotlin `PPG_WAVEFORM_RETENTION_ROWS`),
+ /// added for #1911. This table was previously the only UNBOUNDED blob table, and it carries by far the
+ /// largest PER-ROW cost of any decoded stream: ~120 B against ~30 B for a scalar row.
+ ///
+ /// It is NOT the store's fastest-growing table, and this note must not be read as saying so. v26 runs
+ /// only in optical windows, roughly 28,800 rows/day by #1911's own figures, where `rrInterval` banks
+ /// ~100,000/day and remains the higher-volume table by bytes. Capping this one bounds the worst row,
+ /// not the bulk of #1911's ~93 MB/day.
+ ///
+ /// **A NEWEST-N-ROWS CAP, DELIBERATELY NOT A TIME-WINDOW DROP.** #1911 proposes "dropped after the hot
+ /// window", justifying it as "diagnostic-only". That justification is wrong, and the migration note on
+ /// `ppgWaveformSample` in `Database.swift` is the authority: these rows are kept precisely so a better
+ /// estimator, HRV-from-PPG, or a waveform viewer can later run over the ORIGINAL samples rather than
+ /// the derived bpm. Deleting by wall-clock age would empty the table for exactly the user a future
+ /// estimator needs most — a sporadic wearer, whose v26 seconds are spread thin over months — and a
+ /// waveform, unlike an HR series, has no aggregate that survives it. Newest-N instead bounds the bytes
+ /// while ALWAYS leaving a full working set to analyse, which is the same trade `v18AuxRetentionRows`
+ /// below makes for the same reason.
+ ///
+ /// 604,800 = 7 × 86,400, matching the aux cap's "a week of strap-seconds" semantic and #1911's own
+ /// 7-day hot window. The ceiling is larger than the aux table's because the row is: ~120 B/row (a 48 B
+ /// packed-i16 blob for 24 samples, plus row and primary-key-index overhead) puts it at **~70 MB per
+ /// device**, against ~50 MB for aux. That is the bound worth quoting; the wall-clock
+ /// span is longer than the arithmetic suggests, because v26 only runs in optical windows. At #1911's
+ /// ~28,800 rows/day the cap holds about **three weeks** of typical wear, and proportionally more for a
+ /// sporadic wearer, which is exactly the population an age-based cutoff would have emptied. Retuning is
+ /// a one-constant change with no migration once a device `row_bytes` measurement lands, and RELAXING a
+ /// cap is always cheaper than imposing one on a user with a year of history.
+ public static let ppgWaveformRetentionRows = 604_800
+
+ /// Rows to bank before sweeping `ppgWaveformSample` again, same amortisation as
+ /// `v18AuxPruneEveryRows` below and the same magnitude for the same reason: the sweep walks up to
+ /// `ppgWaveformRetentionRows` index entries, so running it per insert batch is the cost. The table may
+ /// sit this many rows (plus the crossing batch) above the cap in exchange, roughly a MB against its
+ /// ~70 MB bound.
+ ///
+ /// WHAT THIS BUDGET DOES NOT GUARANTEE, and the reason the cap above is stated as a size rather than a
+ /// "hard ceiling": the counter is in-memory and per store instance, so a process restart resets it.
+ /// The sweep is the ONLY thing enforcing retention on this table — `Collector.prune` covers the raw
+ /// outbox alone, and the `*ByTs` deletes belong to `TimestampHeal`, not to retention — so a store that
+ /// never banks this many rows in one process lifetime never sweeps at all. It is not a concern for the
+ /// normal shape (the budget accumulates across every batch of a session, and one night's offload banks
+ /// ~28,800 rows, crossing it twice over), but a store fed only short bursts between app kills can drift
+ /// above the cap indefinitely. `v18AuxPruneEveryRows` below has the identical property; a sweep forced
+ /// once per session would close it for both, and belongs in a change that covers both.
+ public static let ppgWaveformPruneEveryRows = 10_000
/// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`).
///
- /// `rawImuSample` is the closest precedent — raw instrumentation banked as a blob, capped rather than
- /// unbounded — and the same reasoning applies here: nothing reads these rows yet, so a cap is far
+ /// Raw instrumentation must be capped rather than unbounded. Nothing reads these rows yet, so a cap is far
/// cheaper to RELAX later than to impose once users have a year of history. Unbounded, this table is
/// the one genuinely new source of row growth in v31 (the four named channels only WIDEN rows that
/// were already being written: ~14 bytes on a `gravitySample`/`skinTempSample`/`sleepStateSample` row
@@ -102,27 +144,6 @@ extension WhoopStore {
}
}
- /// #423: persist decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16 BLOB),
- /// then bound the table to the newest `retentionRows` for the device (rolling retention). Written from
- /// the deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts).
- /// Twin of Kotlin `WhoopRepository.insertRawImu`.
- public func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws {
- guard !rows.isEmpty else { return }
- try syncWrite { db in
- let ins = try db.cachedStatement(sql: """
- INSERT INTO rawImuSample (deviceId, ts, samples) VALUES (?, ?, ?)
- ON CONFLICT(deviceId, ts) DO NOTHING
- """)
- // Pack the raw i16 columns to the LE BLOB HERE (packImuColumns is module-internal), so the
- // caller passes plain [Int16] and never needs the packer. Mirrors how `insert` packs ppgWaveform.
- for r in rows { try ins.execute(arguments: [deviceId, r.ts, WhoopStore.packImuColumns(r.cols)]) }
- try db.execute(sql: """
- DELETE FROM rawImuSample WHERE deviceId = ? AND ts < (
- SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = ? ORDER BY ts DESC LIMIT ?))
- """, arguments: [deviceId, deviceId, retentionRows])
- }
- }
-
/// Idempotent upsert of decoded streams by natural key. Returns the number of rows
/// ACTUALLY inserted per stream (0 for rows that already existed).
///
@@ -135,7 +156,9 @@ extension WhoopStore {
spo2: Int, skinTemp: Int, resp: Int, gravity: Int) {
try await insert(streams, deviceId: deviceId,
v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
- v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows)
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: WhoopStore.ppgWaveformRetentionRows,
+ ppgWaveformPruneEveryRows: WhoopStore.ppgWaveformPruneEveryRows)
}
/// `insert(_:deviceId:)` with the v31 aux-table cap made explicit. Internal and a SEPARATE overload
@@ -143,13 +166,21 @@ extension WhoopStore {
/// require `insert(_:deviceId:)` exactly, and a Swift witness must match the requirement's parameter
/// list — a default argument does not satisfy it. Exists so a test can prove the rolling delete with a
/// small cap instead of writing 600k rows; every production caller goes through the wrapper above.
+ ///
+ /// The two v18-aux caps are required because eleven existing call sites already pass them; the two
+ /// ppg-waveform caps added for #1911 are DEFAULTED so those same call sites keep compiling untouched.
+ /// A default is fine on this overload (unlike the public entry point, per the note above) because
+ /// nothing witnesses it against a protocol requirement.
@discardableResult
func insert(_ streams: Streams, deviceId: String, v18AuxRetentionRows: Int,
- v18AuxPruneEveryRows: Int) async throws
+ v18AuxPruneEveryRows: Int,
+ ppgWaveformRetentionRows: Int = WhoopStore.ppgWaveformRetentionRows,
+ ppgWaveformPruneEveryRows: Int = WhoopStore.ppgWaveformPruneEveryRows) async throws
-> (hr: Int, rr: Int, events: Int, battery: Int,
spo2: Int, skinTemp: Int, resp: Int, gravity: Int) {
// Banked rows, accumulated across batches so the sweep does not run on every one.
var v18Written = 0
+ var ppgWaveformWritten = 0
let result: (Int, Int, Int, Int, Int, Int, Int, Int) = try syncWrite { db in
var hr = 0, rr = 0, ev = 0, bat = 0
var spo2 = 0, skin = 0, resp = 0, grav = 0
@@ -284,9 +315,15 @@ extension WhoopStore {
INSERT INTO stepSample (deviceId, ts, counter, activityClass) VALUES (?, ?, ?, ?)
ON CONFLICT(deviceId, ts) DO NOTHING
""")
+ var insertedSteps = 0
+ var insertedStepTimestamps: [Int] = []
for s in streams.steps {
try stmt.execute(arguments: [deviceId, s.ts, s.counter, s.activityClass])
+ let inserted = db.changesCount
+ insertedSteps += inserted
+ if inserted > 0 { insertedStepTimestamps.append(s.ts) }
}
+ stepDataRevision.record(deviceId: deviceId, insertedTimestamps: insertedStepTimestamps)
}
// Band sleep_state (#175). Persist-only, same as steps — the strap's OWN @81 high-nibble state
// (0 wake/1 still/2 asleep/3 up), decoded and streamed but dropped at storage until now. Keyed by
@@ -329,6 +366,7 @@ extension WhoopStore {
for s in streams.ppgWaveform {
try stmt.execute(arguments: [deviceId, s.ts, WhoopStore.packPpgSamples(s.samples),
s.burstIndex])
+ ppgWaveformWritten += 1
}
}
// Every remaining v18 slot (v31), one compact blob per strap-second. Persist-only, same as
@@ -350,9 +388,8 @@ extension WhoopStore {
return (hr, rr, ev, bat, spo2, skin, resp, grav)
}
- // Rolling retention (the `insertRawImu` shape, #423) but AMORTISED. The delete finds the
- // Nth-newest row by rank, so it walks up to `v18AuxRetentionRows` index entries; `insertRawImu`
- // keeps 3,600 so that is free, this keeps 604,800 and an offload inserts once per chunk. Swept
+ // Rolling retention is amortised. The delete finds the Nth-newest row by rank, so it walks up to
+ // `v18AuxRetentionRows` index entries. The 604,800-row cap is swept
// once per `v18AuxPruneEveryRows` rows instead, which keeps newest-N-rows exactly (a time window
// would not — a sporadically-worn strap's rows span far more than a week, and the census wants
// that). Counter is per device because the delete is.
@@ -374,6 +411,25 @@ extension WhoopStore {
v18AuxRowsSincePrune[deviceId] = 0
}
}
+ // #1911 rolling retention for the waveform blobs, amortised and best-effort on exactly the same
+ // terms as the aux sweep above (see `ppgWaveformRetentionRows` for why this is a newest-N cap and
+ // not an age-based drop). Its own counter and its own transaction: a batch routinely writes one of
+ // these two tables and not the other, and a failed sweep here must not fail an insert whose rows
+ // are already committed — leaving the budget unspent simply retries on the next batch.
+ if ppgWaveformWritten > 0 {
+ let banked = (ppgWaveformRowsSincePrune[deviceId] ?? 0) + ppgWaveformWritten
+ ppgWaveformRowsSincePrune[deviceId] = banked
+ if banked >= ppgWaveformPruneEveryRows,
+ (try? syncWrite { db in
+ try db.execute(sql: """
+ DELETE FROM ppgWaveformSample WHERE deviceId = ? AND ts < (
+ SELECT MIN(ts) FROM (
+ SELECT ts FROM ppgWaveformSample WHERE deviceId = ? ORDER BY ts DESC LIMIT ?))
+ """, arguments: [deviceId, deviceId, ppgWaveformRetentionRows])
+ }) != nil {
+ ppgWaveformRowsSincePrune[deviceId] = 0
+ }
+ }
return result
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift
index 8093834237..29c959cd13 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift
@@ -52,12 +52,13 @@ extension WhoopStore {
// legacy-rows gap rather than an ongoing one (the #547 ingest gate now rejects an implausible
// ts before it is banked), which is exactly why it went unnoticed. Guarded by the same
// out-of-bounds predicate as everything else — a plausible row is never touched.
- let rawTables = ["hrSample", "rrInterval", "event", "battery",
- "spo2Sample", "skinTempSample", "respSample",
- "gravitySample", "stepSample", "ppgHrSample",
- "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample"]
+ // The SAME list the storage footprint reports, rather than a third copy of it. This one was
+ // called canonical while `storageStats` kept its own and the Apple probe a shorter one again,
+ // and that drift is not hypothetical: `storageStats`' comment records its old fixed sum
+ // omitting six of these tables. One list means a stream added later is healed AND counted AND
+ // named in the breakdown, or none of the three - never silently one of them.
var rawDeleted = 0
- for table in rawTables {
+ for (_, table) in WhoopStore.rawTableKeys {
try db.execute(sql: "DELETE FROM \(table) WHERE ts < ? OR ts > ?",
arguments: [lo, hi])
rawDeleted += db.changesCount
diff --git a/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift b/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift
new file mode 100644
index 0000000000..ec221112e9
--- /dev/null
+++ b/Packages/WhoopStore/Sources/WhoopStore/WhoopSerialIdentity.swift
@@ -0,0 +1,69 @@
+import Foundation
+
+/// The stable WHOOP device id derived from the strap's own serial (#1303).
+///
+/// WHOOP strap identity is otherwise a TRANSIENT CoreBluetooth UUID: a re-pair or factory reset mints a
+/// fresh one, so the same physical strap forks into a second registry row and orphans its history (#1193).
+/// The ring path already solved this — `DeviceRegistryStore.adoptSerialIdentity` re-points a provisional id
+/// onto a serial id and migrates every device-scoped row — and this is the WHOOP half of the same idea.
+///
+/// Pure and store-free so both platforms can pin the composition and, more importantly, the REFUSALS: a
+/// blank or implausible serial must yield nil and leave the existing id alone. Adopting onto a junk id
+/// would be worse than not adopting at all, because the migration moves every row onto it.
+///
+/// Kotlin twin: `WhoopSerialIdentity`.
+public enum WhoopSerialIdentity {
+
+ /// The one place the WHOOP id namespace is spelled. `AddDeviceWizard` mints `whoop-` and
+ /// `DeviceRegistryStore` classifies on the same prefix; a serial id joins the same namespace so every
+ /// existing prefix check keeps working unchanged.
+ public static let idPrefix = "whoop"
+
+ /// Shortest serial worth adopting. A 5.0/MG DIS serial is far longer; this only rejects a truncated or
+ /// placeholder read, which a partial GATT response can produce.
+ public static let minSerialLength = 6
+
+ /// The `whoop-` id for a strap serial, or nil when the serial cannot be trusted to identify it.
+ ///
+ /// Refuses blank/whitespace, anything under `minSerialLength`, and any serial carrying a character
+ /// outside `[A-Z0-9-]` after upper-casing — a DIS read that returns a descriptive string rather than a
+ /// serial should never become a device id. Upper-cased so the same strap read twice, in either case,
+ /// resolves to ONE id rather than two.
+ public static func adoptedId(serial: String?) -> String? {
+ guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
+ let up = raw.uppercased()
+ guard up.count >= minSerialLength else { return nil }
+ let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-")
+ guard up.allSatisfy({ allowed.contains($0) }) else { return nil }
+ return "\(idPrefix)-\(up)"
+ }
+
+ /// Whether this pairing's id may be re-pointed onto a serial id at all.
+ ///
+ /// ONLY a provisional `whoop-` id qualifies. The legacy `my-whoop` seed is deliberately
+ /// EXCLUDED, and that exclusion is what makes this safe to ship before #1304: every existing
+ /// single-WHOOP install is still on that seed, ~47 code paths still read the literal `"my-whoop"`
+ /// directly, and `WhoopBleClient.deviceId` documents that the single-WHOOP path never reassigns it.
+ /// Adopting it would migrate the whole history onto `whoop-` while new samples kept being
+ /// written under `my-whoop` — a split history that reads as data loss.
+ ///
+ /// The legacy seed joins this path as part of #1304, once the literals no longer assume it.
+ public static func mayAdopt(currentId: String) -> Bool {
+ currentId.hasPrefix("\(idPrefix)-")
+ }
+
+ /// True when `id` is already the serial id for `serial` — the steady state on every reconnect after the
+ /// first adoption, and the cheap early-out that keeps re-adoption from doing database work per connect.
+ public static func isAlreadyAdopted(id: String, serial: String?) -> Bool {
+ guard let target = adoptedId(serial: serial) else { return false }
+ return id == target
+ }
+
+ /// What may be written to a SHAREABLE strap log. The serial identifies the device, so only its leading
+ /// characters are ever logged — the same rule `noteWhoop5VariantFromDIS` already applies to the variant
+ /// line. Never log `adoptedId`'s result directly.
+ public static func logSafe(serial: String?) -> String {
+ guard let raw = serial?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return "?" }
+ return String(raw.uppercased().prefix(3)) + "…"
+ }
+}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift b/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift
index e18eab85ec..f75e185afa 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift
@@ -59,9 +59,17 @@ private actor StoreOpenGate {
/// data or the query results.
public actor WhoopStore {
+ /// PPG waveform rows banked since its retention sweep last ran, PER DEVICE, for the same reason as
+ /// `v18AuxRowsSincePrune` below: the sweep is per device, so a shared counter would let one strap
+ /// spend another's budget. Separate counter from the v18 aux one because the two caps differ and a
+ /// batch commonly writes one table and not the other. See `StreamStore`.
+ var ppgWaveformRowsSincePrune: [String: Int] = [:]
+
/// v18 aux rows banked since the retention sweep last ran, PER DEVICE — the sweep is per device too,
/// so a shared counter would let one strap spend another's budget. See `StreamStore`.
var v18AuxRowsSincePrune: [String: Int] = [:]
+ var stepDataRevision = StepDataRevisionIndex()
+ let revisionInstanceToken = UUID().uuidString
let dbWriter: any DatabaseWriter
/// Read-only handle to the underlying GRDB writer for the synchronous `DeviceRegistryStore`.
@@ -271,3 +279,44 @@ public actor WhoopStore {
}
}
}
+
+/// Compact process-local cache witness keyed by owner and UTC day. Cycle windows move with `now`, so
+/// using their exact end timestamp as a cache key would invalidate an otherwise unchanged active cycle
+/// every second. Keeping revisions per UTC day makes the signature stable until a row in that day lands.
+/// Its process-lifetime size is proportional to owners × UTC days that receive new step rows.
+struct StepDataRevisionIndex {
+ private static let utcDaySeconds = 86_400
+ private var nextRevision = 0
+ private var byOwnerDay: [OwnerDay: Int] = [:]
+
+ private struct OwnerDay: Hashable {
+ let owner: String
+ let day: Int
+ }
+
+ mutating func record(deviceId: String, insertedTimestamps: [Int]) {
+ guard !insertedTimestamps.isEmpty else { return }
+ nextRevision += 1
+ for timestamp in insertedTimestamps {
+ byOwnerDay[OwnerDay(owner: deviceId, day: timestamp.floorDividing(by: Self.utcDaySeconds))]
+ = nextRevision
+ }
+ }
+
+ func signature(deviceId: String, from: Int, to: Int) -> String {
+ guard to > from else { return "" }
+ let first = from.floorDividing(by: Self.utcDaySeconds)
+ let last = (to - 1).floorDividing(by: Self.utcDaySeconds)
+ return (first...last).map { day in
+ "\(day):\(byOwnerDay[OwnerDay(owner: deviceId, day: day), default: 0])"
+ }.joined(separator: ",")
+ }
+}
+
+private extension Int {
+ func floorDividing(by divisor: Int) -> Int {
+ let quotient = self / divisor
+ let remainder = self % divisor
+ return remainder < 0 ? quotient - 1 : quotient
+ }
+}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift
index f062dead1d..ee0b4ab5b4 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/BackupSettingsTests.swift
@@ -19,8 +19,10 @@ final class BackupSettingsTests: XCTestCase {
"profile.hrMax": 191,
"profile.hrZoneThresholds": "95,118,142,168,184",
"units.system": "imperial",
+ "units.distance": "metric",
"units.temperature": "celsius",
"effort.scale": "whoop",
+ "dayCycle.mode": "sleep_onset",
// #today-hosted-cards: the one layout pref carried, a JSON [String] stored under the String kind.
"today.hostedCards": "[\"sleep.sleepMarks\"]",
// #1361: custom journal behaviours, a newline-joined name list — the embedded newline must
@@ -38,8 +40,10 @@ final class BackupSettingsTests: XCTestCase {
XCTAssertEqual(back["profile.hrMax"] as? Int, 191)
XCTAssertEqual(back["profile.hrZoneThresholds"] as? String, "95,118,142,168,184")
XCTAssertEqual(back["units.system"] as? String, "imperial")
+ XCTAssertEqual(back["units.distance"] as? String, "metric")
XCTAssertEqual(back["units.temperature"] as? String, "celsius")
XCTAssertEqual(back["effort.scale"] as? String, "whoop")
+ XCTAssertEqual(back["dayCycle.mode"] as? String, "sleep_onset")
XCTAssertEqual(back["today.hostedCards"] as? String, "[\"sleep.sleepMarks\"]")
XCTAssertEqual(back["journal.customBehaviors"] as? String, "Cold plunge\nMagnesium")
XCTAssertEqual(back.count, values.count, "Nothing extra should appear")
@@ -115,12 +119,14 @@ final class BackupSettingsTests: XCTestCase {
defaults.set(82.5, forKey: "profile.weightKg")
defaults.set(198, forKey: "profile.hrMaxOverride") // storage key, not the canonical name
defaults.set("imperial", forKey: "units.system")
+ defaults.set("metric", forKey: "units.distance")
let snap = BackupSettings.snapshot(from: defaults)
XCTAssertEqual(snap["profile.age"] as? Int, 29)
XCTAssertEqual(snap["profile.weightKg"] as? Double, 82.5)
XCTAssertEqual(snap["profile.hrMax"] as? Int, 198, "hrMaxOverride surfaces under the canonical key")
XCTAssertEqual(snap["units.system"] as? String, "imperial")
+ XCTAssertEqual(snap["units.distance"] as? String, "metric")
XCTAssertNil(snap["profile.heightCm"], "Never-set keys are omitted, not defaulted")
XCTAssertNil(snap["profile.sex"])
}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift
index adfef47ac8..752601e119 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DailyMetricLastVitalsDayTests.swift
@@ -151,4 +151,85 @@ final class DailyMetricLastVitalsDayTests: XCTestCase {
fieldDay("2999-01-01", skinTemp: 2.1)]
XCTAssertEqual(DailyMetric.lastSkinTempDay(days: days, todayKey: "2026-06-19")?.day, "2026-06-16")
}
+
+ // MARK: PER-FIELD HRV / resting HR — the OR predicate's blind spot (#1842)
+
+ /// The reported shape: the freshest row carries respiratory ONLY, so the OR predicate selects it and its
+ /// nil `avgHrv` renders "—" on the card while a tile carrying a different row shows a number on the same
+ /// screen. Pins the BUG as well as the fix, so a future simplification back onto `lastVitalsDay` fails
+ /// here rather than in the field.
+ func testSharedPredicateSelectsARowWithNoHrv_perFieldDoesNot() {
+ let days = [day("2026-09-01", hrv: 35, rhr: 58),
+ day("2026-09-02", resp: 14.2),
+ day("2026-09-03")] // today, nothing yet
+ let shared = DailyMetric.lastVitalsDay(days: days, todayKey: "2026-09-03")
+ XCTAssertEqual(shared?.day, "2026-09-02", "the OR predicate takes the respiratory-only row")
+ XCTAssertNil(shared?.avgHrv, "...and that row has no HRV — the blank the card was rendering")
+
+ XCTAssertEqual(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03")?.avgHrv, 35)
+ XCTAssertEqual(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03")?.restingHr, 58)
+ }
+
+ /// Freshest wins when several prior rows carry the field.
+ func testPerFieldCarriesTheFreshestRowHoldingTheField() {
+ let days = [day("2026-09-01", hrv: 30, rhr: 62),
+ day("2026-09-02", hrv: 41, rhr: 55),
+ day("2026-09-03")]
+ XCTAssertEqual(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03")?.avgHrv, 41)
+ XCTAssertEqual(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03")?.restingHr, 55)
+ }
+
+ /// Same future-clock bound as every sibling: strictly prior to today's key, so today's own still-forming
+ /// row and a bad-clock strap's future-dated row can never be carried as "last night's".
+ func testPerFieldNeverCarriesTodayOrLater() {
+ let days = [day("2026-09-03", hrv: 44, rhr: 50), day("2026-09-04", hrv: 45, rhr: 49)]
+ XCTAssertNil(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03"))
+ XCTAssertNil(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03"))
+ }
+
+ /// Nothing anywhere stays "—": the carry must never invent a value.
+ func testPerFieldCarriesNothingWhenNoRowHasTheField() {
+ let days = [day("2026-09-01", resp: 15.0), day("2026-09-02", resp: 14.0)]
+ XCTAssertNil(DailyMetric.lastHrvDay(days: days, todayKey: "2026-09-03"))
+ XCTAssertNil(DailyMetric.lastRestingHrDay(days: days, todayKey: "2026-09-03"))
+ }
+
+
+ // MARK: skin-temp reading row — EITHER number, one night (#1844)
+
+ private func skinDay(_ key: String, abs: Double? = nil, dev: Double? = nil) -> DailyMetric {
+ DailyMetric(day: key, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil,
+ lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, recovery: nil,
+ strain: nil, exerciseCount: nil, spo2Pct: nil, skinTempDevC: dev,
+ respRateBpm: nil, skinTempC: abs)
+ }
+
+ /// The calibrating night the deviation-only selector skipped: a real temperature, no deviation yet.
+ func testFindsANightWithOnlyAnAbsolute() {
+ let days = [skinDay("2026-09-01", dev: -0.3), skinDay("2026-09-02", abs: 34.1)]
+ XCTAssertEqual(DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03")?.day, "2026-09-02")
+ // The deviation-only row reaches back past it, which is what left the card empty.
+ XCTAssertEqual(DailyMetric.lastSkinTempDay(days: days, todayKey: "2026-09-03")?.day, "2026-09-01")
+ }
+
+ /// Both numbers come off ONE row, so an absolute is never paired with another night's deviation.
+ func testBothSkinNumbersComeFromOneNight() {
+ let days = [skinDay("2026-09-01", abs: 33.0, dev: -0.9), skinDay("2026-09-02", abs: 34.1, dev: 0.2)]
+ let row = DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03")
+ XCTAssertEqual(row?.skinTempC, 34.1)
+ XCTAssertEqual(row?.skinTempDevC, 0.2)
+ }
+
+ /// Same future-clock bound as every sibling carry.
+ func testSkinReadingRowNeverCarriesTodayOrLater() {
+ let days = [skinDay("2026-09-03", abs: 34.0), skinDay("2026-09-04", abs: 34.2)]
+ XCTAssertNil(DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03"))
+ }
+
+ /// A row with neither number is not a reading.
+ func testSkinRowsWithNeitherNumberAreSkipped() {
+ let days = [skinDay("2026-09-01", abs: 33.5), skinDay("2026-09-02")]
+ XCTAssertEqual(DailyMetric.lastSkinTempReadingDay(days: days, todayKey: "2026-09-03")?.day, "2026-09-01")
+ }
+
}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift
index 6a43ebdbdb..f6d9f884f3 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift
@@ -70,4 +70,58 @@ final class DailySkinTempAbsoluteTests: XCTestCase {
XCTAssertEqual(try XCTUnwrap(rows.first?.skinTempC), 34.4, accuracy: 0.001,
"a re-score must be able to correct the stored absolute")
}
+
+ // MARK: - #1853: fill-only write (`fillSkinTempC`)
+
+ /// The fill-only write fills a NULL `skinTempC` and touches NOTHING else — the deviation, recovery,
+ /// HRV, and every other scored field stay exactly as the scoring pass left them. This is the
+ /// non-negotiable: a single-column write that can only fill a NULL, never an upsert of a rebuilt row.
+ func testFillSkinTempC_fillsNullAndTouchesNothingElse() async throws {
+ let store = try await WhoopStore.inMemory()
+ // A scored night with a deviation but no absolute — the backfill's exact candidate.
+ let d = DailyMetric(day: "2026-08-14", totalSleepMin: 415, efficiency: 0.9,
+ deepMin: 88, remMin: 108, lightMin: 219, disturbances: 2,
+ restingHr: 50, avgHrv: 62.0, recovery: 0.06, strain: 10.5,
+ exerciseCount: 1, spo2Pct: 96.2, skinTempDevC: 0.52, respRateBpm: 14.7,
+ steps: 7_900, activeKcalEst: 2_180.0, avgSdnn: 88.4, skinTempC: nil)
+ try await store.upsertDailyMetrics([d], deviceId: "devA")
+
+ let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6)
+ XCTAssertEqual(filled, 1, "exactly one row filled")
+
+ let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-14", to: "2026-08-14")
+ let row = try XCTUnwrap(rows.first)
+ XCTAssertEqual(try XCTUnwrap(row.skinTempC), 34.6, accuracy: 0.001, "the absolute was filled")
+ // Every other field is untouched — the fill-only write touched one column.
+ XCTAssertEqual(try XCTUnwrap(row.skinTempDevC), 0.52, accuracy: 0.001, "deviation untouched")
+ XCTAssertEqual(try XCTUnwrap(row.recovery), 0.06, accuracy: 0.001, "recovery untouched")
+ XCTAssertEqual(try XCTUnwrap(row.avgHrv), 62.0, accuracy: 0.001, "HRV untouched")
+ XCTAssertEqual(try XCTUnwrap(row.restingHr), 50, "resting HR untouched")
+ XCTAssertEqual(try XCTUnwrap(row.totalSleepMin), 415, accuracy: 0.001, "sleep untouched")
+ }
+
+ /// The fill-only write REFUSES to overwrite a measured value. A night that already has an absolute
+ /// is left alone — the backfill must never clobber a value the scoring pass stored.
+ func testFillSkinTempC_refusesToOverwriteAnExistingAbsolute() async throws {
+ let store = try await WhoopStore.inMemory()
+ let d = DailyMetric(day: "2026-08-14", totalSleepMin: 415, efficiency: 0.9,
+ deepMin: 88, remMin: 108, lightMin: 219, disturbances: 2,
+ restingHr: 50, avgHrv: 62.0, recovery: 0.06, strain: 10.5,
+ exerciseCount: 1, skinTempDevC: 0.52, skinTempC: 33.9)
+ try await store.upsertDailyMetrics([d], deviceId: "devA")
+
+ let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6)
+ XCTAssertEqual(filled, 0, "an existing absolute must NOT be overwritten")
+
+ let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-14", to: "2026-08-14")
+ XCTAssertEqual(try XCTUnwrap(rows.first?.skinTempC), 33.9, accuracy: 0.001,
+ "the original measured value survives")
+ }
+
+ /// The fill-only write is a no-op when the row doesn't exist (no orphan rows created).
+ func testFillSkinTempC_noOpWhenRowDoesNotExist() async throws {
+ let store = try await WhoopStore.inMemory()
+ let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6)
+ XCTAssertEqual(filled, 0, "no row to fill — no orphan created")
+ }
}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift
index 2e5d4a9c6b..4d7f0794b6 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift
@@ -347,7 +347,6 @@ final class DeepCaptureChannelsTests: XCTestCase {
/// The shipped cap is a real bound, and documented in row terms.
func testShippedRetentionConstant() {
XCTAssertEqual(WhoopStore.v18AuxRetentionRows, 604_800) // 7 x 86_400 strap-seconds
- XCTAssertGreaterThan(WhoopStore.v18AuxRetentionRows, WhoopStore.rawImuRetentionRows)
}
/// Deleting a device's data must clear the aux rows too — the same privacy rule every other
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift
index 25dd08f32a..51ae1bfbc4 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DeviceRegistryStoreTests.swift
@@ -222,6 +222,52 @@ final class DeviceRegistryStoreTests: XCTestCase {
XCTAssertEqual(row?.model, "Oura Ring 3")
}
+ /// The computed sibling must travel with the pairing.
+ ///
+ /// Every strap owns a second id, `-noop`, holding the days/workouts/series the engine
+ /// DERIVES. It never equals `activeId`, so an exact-match re-key left it behind while the next scoring
+ /// pass wrote under `-noop` — stranding the computed history under an id nothing reads
+ /// again, which is the orphaned-history failure adoption exists to prevent. A ring has no computed
+ /// sibling, so the shipped Oura path could never surface this.
+ func testAdoptSerialCarriesTheComputedSibling() throws {
+ let dbq = try makeDB(); let store = DeviceRegistryStore(dbQueue: dbq)
+ let cbuuid = "whoop-4DD70E24", serial = "whoop-MGB1234567"
+ try addOura(store, cbuuid, peripheralId: "4DD70E24", status: .paired, addedAt: 100)
+ try store.setActive(cbuuid)
+ try dbq.write { db in
+ try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-4DD70E24', 10, 55)")
+ // the DERIVED half, under the computed sibling
+ try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-4DD70E24-noop', 11, 56)")
+ }
+
+ XCTAssertTrue(try store.adoptSerialIdentity(from: cbuuid, to: serial))
+
+ XCTAssertEqual(try hrCount(dbq, serial), 1)
+ XCTAssertEqual(try hrCount(dbq, cbuuid), 0)
+ XCTAssertEqual(try hrCount(dbq, serial + "-noop"), 1, "computed rows must follow the pairing")
+ XCTAssertEqual(try hrCount(dbq, cbuuid + "-noop"), 0, "and must not be left behind")
+ }
+
+ /// A PK clash on the COMPUTED side resolves the same way as on the real id: canonical wins, source is
+ /// cleared, nothing is duplicated. Worth pinning separately because the clash is reachable only after
+ /// a re-pair that already scored days under the serial's own computed sibling.
+ func testAdoptSerialMergesComputedSiblingsOnClash() throws {
+ let dbq = try makeDB(); let store = DeviceRegistryStore(dbQueue: dbq)
+ let cbuuid = "whoop-0102A826", serial = "whoop-MGB7654321"
+ try addOura(store, serial, peripheralId: "OLDPID", status: .paired, addedAt: 100)
+ try addOura(store, cbuuid, peripheralId: "0102A826", status: .paired, addedAt: 200)
+ try store.setActive(cbuuid)
+ try dbq.write { db in
+ try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-MGB7654321-noop', 10, 50)")
+ try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('whoop-0102A826-noop', 20, 60)")
+ }
+
+ try store.adoptSerialIdentity(from: cbuuid, to: serial)
+
+ XCTAssertEqual(try hrCount(dbq, serial + "-noop"), 2)
+ XCTAssertEqual(try hrCount(dbq, cbuuid + "-noop"), 0)
+ }
+
func testAdoptSerialMergesWhenSerialAlreadyExists() throws {
let dbq = try makeDB(); let store = DeviceRegistryStore(dbQueue: dbq)
let serial = "oura-2H3B2405003655", cbuuid2 = "oura-0102A826"
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift
new file mode 100644
index 0000000000..c6682befe3
--- /dev/null
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/HypnogramCoverageTests.swift
@@ -0,0 +1,253 @@
+import XCTest
+@testable import WhoopStore
+
+/// `HypnogramCoverage` — the ratio that tells a well-formed-looking stage timeline from one that
+/// describes only part of the night it claims.
+final class HypnogramCoverageTests: XCTestCase {
+
+ /// Segments tiling `[0, span)` in 30 s steps, `n` of them, starting at `from`.
+ private func segs(_ ranges: [(Int, Int, String)]) -> String {
+ "[" + ranges.map { "{\"start\":\($0.0),\"end\":\($0.1),\"stage\":\"\($0.2)\"}" }
+ .joined(separator: ",") + "]"
+ }
+
+ // MARK: - the ratio itself
+
+ func testFractionIsCoveredOverSpan() {
+ XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: 300, spanSeconds: 600)!, 0.5, accuracy: 1e-12)
+ XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: 600, spanSeconds: 600)!, 1.0, accuracy: 1e-12)
+ }
+
+ /// Overlapping/overhanging segments would otherwise report more than a whole night. A completeness
+ /// gate must read that as "complete", never manufacture a failure out of malformed input.
+ func testFractionClampsAboveOne() {
+ XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: 900, spanSeconds: 600)!, 1.0, accuracy: 1e-12)
+ }
+
+ /// nil means "unknown, do not judge" — distinct from 0, which a caller comparing against
+ /// `minCoverage` would read as a bad night.
+ func testFractionNilWhenNothingToMeasure() {
+ XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 300, spanSeconds: 0))
+ XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 300, spanSeconds: -1))
+ XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 0, spanSeconds: 600))
+ }
+
+ // MARK: - from a stored payload
+
+ func testTilingTimelineCoversItsSpan() {
+ let json = segs([(0, 300, "light"), (300, 600, "deep")])
+ XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 600)!, 1.0, accuracy: 1e-12)
+ XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 600))
+ }
+
+ /// The shape this whole change exists for: a hypnogram assembled from records that arrived
+ /// incomplete. Many segments, all real, spanning a night they only partly describe. The measured
+ /// worst case was 140 minutes of segments across a 601-minute span.
+ func testHoledTimelineIsDetected() {
+ let json = segs([(0, 4200, "light"), (4200, 8400, "deep")]) // 140 min over a 601 min span
+ let span = 601.0 * 60.0
+ let f = HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: span)!
+ XCTAssertEqual(f, 8400.0 / span, accuracy: 1e-12)
+ XCTAssertLessThan(f, 0.24)
+ XCTAssertTrue(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: span))
+ }
+
+ /// A hole in the MIDDLE is the real failure mode (a page that never arrived), not a short tail.
+ func testInteriorHoleCounts() {
+ let json = segs([(0, 300, "light"), (900, 1200, "deep")]) // 600 s of 1200 s
+ XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1200)!, 0.5, accuracy: 1e-12)
+ }
+
+ func testThresholdBoundaryIsInclusive() {
+ // exactly minCoverage is NOT holed; a hair under it is.
+ let atGate = segs([(0, 950, "light")])
+ let underGate = segs([(0, 949, "light")])
+ XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: atGate, spanSeconds: 1000))
+ XCTAssertTrue(HypnogramCoverage.isHoled(stagesJSON: underGate, spanSeconds: 1000))
+ }
+
+ // MARK: - what must NOT be judged
+
+ /// The imported minute-dict shape carries no timestamps, so coverage is unanswerable. It must come
+ /// back nil (not 0), which is what keeps every WHOOP/Apple/Health-Connect import out of this gate.
+ func testImportedMinuteDictIsUnmeasurable() {
+ let dict = "{\"light\":300,\"deep\":100,\"rem\":80,\"awake\":40}"
+ XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: dict, spanSeconds: 3600))
+ XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: dict, spanSeconds: 3600))
+ }
+
+ func testEmptyAndMalformedAreUnmeasurable() {
+ for payload in [nil, "", " ", "[]", "not json"] as [String?] {
+ XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: payload, spanSeconds: 3600),
+ "payload \(String(describing: payload)) should be unmeasurable")
+ XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: payload, spanSeconds: 3600),
+ "an unmeasurable payload must never read as holed")
+ }
+ }
+
+ /// Every guard built on this fails OPEN: unknown coverage keeps the previous behaviour rather than
+ /// downgrading a night on no evidence.
+ func testSessionOverloadUsesItsOwnSpan() {
+ let holed = CachedSleepSession(startTs: 0, endTs: 1200, efficiency: nil, restingHr: nil,
+ avgHrv: nil, stagesJSON: segs([(0, 300, "light")]))
+ let whole = CachedSleepSession(startTs: 0, endTs: 1200, efficiency: nil, restingHr: nil,
+ avgHrv: nil, stagesJSON: segs([(0, 1200, "light")]))
+ let stageless = CachedSleepSession(startTs: 0, endTs: 1200, efficiency: nil, restingHr: nil,
+ avgHrv: nil, stagesJSON: nil)
+ XCTAssertTrue(HypnogramCoverage.isHoled(holed))
+ XCTAssertFalse(HypnogramCoverage.isHoled(whole))
+ XCTAssertFalse(HypnogramCoverage.isHoled(stageless))
+ }
+
+ // MARK: - shapes the two readers must agree on
+
+ /// The four payloads on which Swift and Kotlin originally DISAGREED, found by compiling both and
+ /// running them rather than by reading them side by side. Swift's whole-array cast makes one
+ /// non-object element poison the payload; the Kotlin twin used to skip that element and measure the
+ /// remainder, reading 0.1 and HOLED where this side read nil and not-holed. It now bails the same
+ /// way. Kept here so the agreement is asserted from BOTH sides, not only in the Kotlin oracle.
+ func testNonObjectElementMakesThePayloadUnmeasurable() {
+ for json in [#"[{"start":0,"end":100,"stage":"deep"},5]"#,
+ #"[{"start":0,"end":100,"stage":"deep"},null]"#,
+ #"[{"start":0,"end":100,"stage":"deep"},"x"]"#] {
+ XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1000),
+ "one non-object element must make the whole payload unmeasurable")
+ XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 1000))
+ }
+ }
+
+ /// A string-valued bound is SKIPPED, not parsed: `NSString` is not an `NSNumber`. The Kotlin twin
+ /// reached the same answer only after dropping `optDouble`, which parses `"0"` happily.
+ func testStringBoundsAreNotCounted() {
+ let json = #"[{"start":"0","end":"100","stage":"deep"}]"#
+ XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1000))
+ }
+
+ /// A bool bound DOES convert, on both sides — `JSONSerialization` bridges `true` to `NSNumber` 1.
+ /// Absurd in a stage payload and unreachable from any producer, pinned because it is the one case
+ /// where the tidy-looking alignment (reject anything that is not a number) would have been wrong:
+ /// under Foundation `{"start":0}` reports `is Bool == true`, so a bool exclusion would have thrown
+ /// away every timeline whose first segment starts at zero.
+ func testBoolBoundConvertsOnBothSides() {
+ let json = #"[{"start":true,"end":100,"stage":"deep"}]"#
+ XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 1000)!, 0.099, accuracy: 1e-12)
+ }
+
+ /// SCOPE: a timestamped import is judged like any other timeline. This is the Xiaomi Band shape —
+ /// real `{start,end,stage}` segments whose span comes from separate bed/wake fields — and it is the
+ /// one importer this gate reaches. Timestamp-free imports stay exempt, asserted above.
+ func testTimestampedImportIsInScope() {
+ let json = segs([(0, 3600, "light")])
+ XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 28800)!, 0.125, accuracy: 1e-12)
+ XCTAssertTrue(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 28800))
+ }
+
+ /// The ENGINE call site sums coverage off decoded segments, not off `stagesJSON`, so the payload
+ /// shape rule that exempts timestamp-free imports at the merge does not run there. What actually
+ /// protects them is this: a group with no timestamped stages at all covers zero, and zero cover is
+ /// unmeasurable rather than bad. Holds only while a group is single-sourced — a group mixing a
+ /// staged fragment with a minute-dict one covers part of its total span and reads as holed.
+ func testGroupWithNoTimestampedStagesIsUnmeasurableNotHoled() {
+ XCTAssertNil(HypnogramCoverage.fraction(coveredSeconds: 0, spanSeconds: 8 * 3600))
+ }
+
+ func testMixedSourceGroupReadsAsHoled() {
+ // 8 h fully-staged fragment + a 2 h minute-dict fragment that decodes to no segments.
+ let covered = 8.0 * 3600, span = 10.0 * 3600
+ XCTAssertEqual(HypnogramCoverage.fraction(coveredSeconds: covered, spanSeconds: span)!, 0.8, accuracy: 1e-12)
+ XCTAssertLessThan(0.8, HypnogramCoverage.minCoverage)
+ }
+
+ /// Health Connect's REAL payload shape. The `minute-dict` case above is the throwing
+ /// `{light,deep,rem,awake}` object; HC emits an ARRAY of `{stage,min}` objects, which parses
+ /// cleanly and reaches the segment loop. It is the live producer shape this gate claims to be
+ /// exempt from, so it is pinned rather than argued: every element is an object (no whole-cast
+ /// failure), none carries bounds (every segment skipped), cover stays 0, and zero cover is
+ /// unmeasurable rather than bad.
+ func testHealthConnectStageMinArrayIsUnmeasurable() {
+ let json = #"[{"stage":"light","min":300},{"stage":"deep","min":100}]"#
+ XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: json, spanSeconds: 3600))
+ XCTAssertFalse(HypnogramCoverage.isHoled(stagesJSON: json, spanSeconds: 3600))
+ }
+
+ /// A JSON null BOUND inside an otherwise well-formed object: the object is fine, so neither side
+ /// bails; both fail to read the bound and skip the segment.
+ func testNullBoundSkipsTheSegment() {
+ XCTAssertNil(HypnogramCoverage.fraction(stagesJSON: #"[{"start":null,"end":100,"stage":"deep"}]"#,
+ spanSeconds: 1000))
+ }
+
+ // MARK: - the GROUP accumulation (what a night is actually judged on)
+
+ private func sess(_ start: Int, _ end: Int, _ stagesJSON: String?) -> CachedSleepSession {
+ CachedSleepSession(startTs: start, endTs: end, efficiency: nil, restingHr: nil,
+ avgHrv: nil, stagesJSON: stagesJSON)
+ }
+
+ /// A night is not one row. Two bridged fragments that are individually 90% and 100% covered are ONE
+ /// night at neither figure, and the group answer is what the engine gates on — so the per-row reading
+ /// is the wrong question for anything presenting a night.
+ func testGroupFractionSumsAcrossBridgedFragments() {
+ let a = sess(0, 1000, segs([(0, 900, "light")])) // 90% of its own span
+ let b = sess(1200, 2200, segs([(1200, 2200, "deep")])) // 100% of its own span
+ XCTAssertEqual(HypnogramCoverage.groupFraction([a, b])!, 1900.0 / 2000.0, accuracy: 1e-12)
+ XCTAssertEqual(HypnogramCoverage.fraction(stagesJSON: a.stagesJSON, spanSeconds: 1000)!,
+ 0.9, accuracy: 1e-12)
+ XCTAssertFalse(HypnogramCoverage.isHoledGroup([a, b]))
+ XCTAssertTrue(HypnogramCoverage.isHoled(a)) // and the fragment alone still is
+ }
+
+ /// The inter-fragment GAP is not in the denominator. It belongs to no fragment's `[startTs, endTs)`,
+ /// and it is known-awake out-of-bed time (#777/#705) rather than time we failed to observe — folding
+ /// it in would report a correctly-recorded biphasic night as partly missing.
+ func testGroupFractionExcludesTheInterFragmentGap() {
+ let a = sess(0, 1000, segs([(0, 1000, "light")]))
+ let b = sess(5000, 6000, segs([(5000, 6000, "deep")])) // 4000 s gap between them
+ XCTAssertEqual(HypnogramCoverage.groupFraction([a, b])!, 1.0, accuracy: 1e-12)
+ }
+
+ /// `testMixedSourceGroupReadsAsHoled`'s arithmetic, now asserted through the accumulation itself
+ /// rather than restated as two literals: a timestamp-free fragment contributes span and no cover.
+ func testGroupFractionMixedSourceReadsAsHoled() {
+ let staged = sess(0, 8 * 3600, segs([(0, 8 * 3600, "light")]))
+ let minuteDict = sess(8 * 3600, 10 * 3600, #"{"light":60,"deep":30,"rem":20,"awake":10}"#)
+ XCTAssertEqual(HypnogramCoverage.groupFraction([staged, minuteDict])!, 0.8, accuracy: 1e-12)
+ XCTAssertTrue(HypnogramCoverage.isHoledGroup([staged, minuteDict]))
+ }
+
+ /// All-timestamp-free: zero cover over a real span is UNKNOWN, not bad. This is what keeps the gate
+ /// off the WHOOP CSV / Apple / Health Connect imports at the group level too.
+ func testGroupFractionAllTimestampFreeIsUnmeasurable() {
+ let a = sess(0, 4 * 3600, #"{"light":60,"deep":30,"rem":20,"awake":10}"#)
+ let b = sess(4 * 3600, 8 * 3600, nil)
+ XCTAssertNil(HypnogramCoverage.groupFraction([a, b]))
+ XCTAssertFalse(HypnogramCoverage.isHoledGroup([a, b]))
+ XCTAssertFalse(HypnogramCoverage.isHoledGroup([]))
+ }
+
+ /// The REAL night this guard's UI was built against — 2026-08-28/29 on an Oura ring: a 470.5-minute
+ /// window whose 96 stage segments account for 440.0 minutes. It renders as a complete night and is
+ /// not; at 93.5% it is the case the Sleep tab's "Partly recorded" note exists to state. Pinned with
+ /// the boundary night from the same 31-night run (08-26, 95.2%) so the gate is shown to separate
+ /// them rather than to flag everything nearby.
+ func testGroupFractionMatchesTheObservedHoledNight() {
+ let holed = sess(0, Int(470.5 * 60), segs([(0, Int(440.0 * 60), "light")]))
+ let f = HypnogramCoverage.groupFraction([holed])!
+ XCTAssertEqual(f, 440.0 / 470.5, accuracy: 1e-12)
+ XCTAssertEqual(Int((f * 100).rounded(.down)), 93) // the percentage the note prints
+ XCTAssertTrue(HypnogramCoverage.isHoledGroup([holed]))
+
+ let boundary = sess(0, 100_000, segs([(0, 95_200, "light")]))
+ XCTAssertFalse(HypnogramCoverage.isHoledGroup([boundary]))
+ }
+
+ /// The note floors its percentage rather than rounding it: 94.8% must never print as "95%" beside a
+ /// badge raised because coverage fell BELOW 95%. Pinned here because the rule is a property of the
+ /// gate, not of one screen, and both hosts that print it must agree.
+ func testPrintedPercentageNeverContradictsTheGate() {
+ let s = sess(0, 100_000, segs([(0, 94_800, "light")]))
+ let f = HypnogramCoverage.groupFraction([s])!
+ XCTAssertTrue(f < HypnogramCoverage.minCoverage)
+ XCTAssertEqual(Int((f * 100).rounded(.down)), 94)
+ }
+}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift
index f6cb7d549a..bd7fb5a0fc 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift
@@ -32,15 +32,87 @@ final class MetricsCacheTests: XCTestCase {
XCTAssertEqual(rows.count, 1)
XCTAssertEqual(rows[0], s)
- // Re-upsert the same natural key with updated values → no duplicate, value updated.
+ // Re-upsert the same natural key with updated values, stages covering their (shorter) new span
+ // just as fully as before → no duplicate, value updated (an ordinary refresh, not a regression).
let s2 = CachedSleepSession(startTs: 1000, endTs: 6000, efficiency: 0.95,
- restingHr: 50, avgHrv: 70.0, stagesJSON: nil)
+ restingHr: 50, avgHrv: 70.0,
+ stagesJSON: "[{\"start\":1000,\"end\":6000,\"stage\":\"light\"}]")
try await store.upsertSleepSessions([s2], deviceId: "devA")
rows = try await store.sleepSessions(deviceId: "devA", from: 0, to: 100_000, limit: 100)
XCTAssertEqual(rows.count, 1, "same (deviceId,startTs) must not duplicate")
XCTAssertEqual(rows[0].endTs, 6000)
XCTAssertEqual(rows[0].efficiency, 0.95)
- XCTAssertNil(rows[0].stagesJSON)
+ XCTAssertEqual(rows[0].stagesJSON, s2.stagesJSON)
+ }
+
+ /// A candidate whose stage timeline is LESS complete than what's already stored for the same
+ /// (deviceId, startTs) must be dropped whole — a device serving the same night's hypnogram twice (a
+ /// reconnect mid-day, a resumed drain) must never have its later, thinner decode clobber an earlier,
+ /// fuller one. Reproduces the exact shape of a real incident: a full-night hypnogram (96 segments,
+ /// fully covering its span) overwritten by a 5-segment tail slice covering only ~10% of the same
+ /// claimed span.
+ func testUpsertSleepSessionsDropsALessCompleteCandidate() async throws {
+ let store = try await WhoopStore.inMemory()
+ let full = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.886,
+ restingHr: 50, avgHrv: 60,
+ stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"light\"}]")
+ try await store.upsertSleepSessions([full], deviceId: "oura-ring")
+
+ // Same night, same key, but the "candidate" only covers the tail ~10% of the claimed span —
+ // holed, per HypnogramCoverage — with a bogus efficiency recomputed off just that slice.
+ let holedTail = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_532, efficiency: 0.99,
+ restingHr: 48, avgHrv: 58,
+ stagesJSON: "[{\"start\":1029411,\"end\":1032532,\"stage\":\"rem\"}]")
+ try await store.upsertSleepSessions([holedTail], deviceId: "oura-ring")
+
+ let rows = try await store.sleepSessions(deviceId: "oura-ring", from: 0, to: 2_000_000, limit: 100)
+ XCTAssertEqual(rows.count, 1)
+ XCTAssertEqual(rows[0], full, "the fuller, previously-stored night must survive untouched")
+ }
+
+ /// The mirror case: a candidate that IMPROVES on a holed stored row (fills in more of the same span)
+ /// must still win — the guard compares richness, it does not freeze the row forever.
+ func testUpsertSleepSessionsAcceptsAMoreCompleteCandidate() async throws {
+ let store = try await WhoopStore.inMemory()
+ let holed = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.5,
+ restingHr: 50, avgHrv: 60,
+ stagesJSON: "[{\"start\":1000000,\"end\":1004000,\"stage\":\"light\"}]")
+ try await store.upsertSleepSessions([holed], deviceId: "oura-ring")
+
+ let full = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.886,
+ restingHr: 48, avgHrv: 58,
+ stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"light\"}]")
+ try await store.upsertSleepSessions([full], deviceId: "oura-ring")
+
+ let rows = try await store.sleepSessions(deviceId: "oura-ring", from: 0, to: 2_000_000, limit: 100)
+ XCTAssertEqual(rows.count, 1)
+ XCTAssertEqual(rows[0], full, "a candidate that covers more of the night must still win")
+ }
+
+ /// The completeness guard must never engage for a user-edited row — `applySleepEdit`'s bounds/stages
+ /// are the dedicated correction path, and `upsertSleepSessions` already protects them unconditionally
+ /// regardless of richness. (Guards against the new check accidentally widening what "protected" means.)
+ func testUpsertSleepSessionsGuardNeverOverridesUserEditedProtection() async throws {
+ let store = try await WhoopStore.inMemory()
+ let full = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.886,
+ restingHr: 50, avgHrv: 60,
+ stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"light\"}]")
+ try await store.upsertSleepSessions([full], deviceId: "oura-ring")
+ _ = try await store.applySleepEdit(deviceId: "oura-ring", detectedStartTs: 1_000_000,
+ newStartTs: 1_000_000, newEndTs: 1_032_531,
+ stagesJSON: "[{\"start\":1000000,\"end\":1032531,\"stage\":\"deep\"}]")
+
+ // A richer-still re-decode arrives; the user's edit must still win untouched.
+ let richer = CachedSleepSession(startTs: 1_000_000, endTs: 1_032_531, efficiency: 0.9,
+ restingHr: 47, avgHrv: 57,
+ stagesJSON: "[{\"start\":1000000,\"end\":1016000,\"stage\":\"light\"}," +
+ "{\"start\":1016000,\"end\":1032531,\"stage\":\"rem\"}]")
+ try await store.upsertSleepSessions([richer], deviceId: "oura-ring")
+
+ let rows = try await store.sleepSessions(deviceId: "oura-ring", from: 0, to: 2_000_000, limit: 100)
+ XCTAssertEqual(rows.count, 1)
+ XCTAssertTrue(rows[0].userEdited)
+ XCTAssertEqual(rows[0].stagesJSON, "[{\"start\":1000000,\"end\":1032531,\"stage\":\"deep\"}]")
}
/// #345: the persisted sparse-coverage flag survives write→read, REFRESHES on a recompute (excluded
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift
index d3217e97b0..59281e0c14 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift
@@ -108,4 +108,135 @@ final class PpgWaveformSampleTests: XCTestCase {
data.append(0xFF)
XCTAssertEqual(WhoopStore.unpackPpgSamples(data), [1, 2, 3])
}
+
+ // MARK: - #1911 rolling retention
+
+ private func retentionStore() async throws -> WhoopStore {
+ let s = try await WhoopStore.inMemory()
+ try await s.upsertDevice(id: "dev1", mac: nil, name: nil)
+ return s
+ }
+
+ private func waveform(_ ts: Int) -> Streams {
+ Streams(ppgWaveform: [PpgWaveformSample(ts: ts, samples: realSamples, burstIndex: 0)])
+ }
+
+ /// The cap keeps the NEWEST rows. This is the property the whole design rests on — an age-based drop
+ /// would instead empty the table for a sporadic wearer, whom a future PPG estimator needs most.
+ func testRetentionKeepsTheNewestRows() async throws {
+ let s = try await retentionStore()
+ for ts in 100...105 {
+ _ = try await s.insert(waveform(ts), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 2, ppgWaveformPruneEveryRows: 1)
+ }
+ let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000)
+ XCTAssertEqual(rows.map(\.ts), [104, 105], "newest-N, not oldest-N and not everything")
+ }
+
+ /// Under the sweep threshold nothing is evicted — the overshoot is deliberate amortisation.
+ func testRetentionDoesNotSweepUnderTheThreshold() async throws {
+ let s = try await retentionStore()
+ for ts in 100...104 {
+ _ = try await s.insert(waveform(ts), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 2, ppgWaveformPruneEveryRows: 50)
+ }
+ let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000)
+ XCTAssertEqual(rows.count, 5, "under the threshold the sweep must not run — overshoot is the point")
+ }
+
+ /// The counter resets after each sweep, so a long offload sweeps repeatedly rather than latching once.
+ func testRetentionCounterResetsAfterEachSweep() async throws {
+ let s = try await retentionStore()
+ for ts in 100...111 {
+ _ = try await s.insert(waveform(ts), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 2, ppgWaveformPruneEveryRows: 4)
+ }
+ let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000)
+ XCTAssertEqual(rows.map(\.ts), [110, 111],
+ "a second sweep must happen; the counter cannot latch after the first")
+ }
+
+ /// The budget is per device, because the delete is — one strap must not spend another's.
+ func testRetentionBudgetIsNotSharedBetweenDevices() async throws {
+ let s = try await retentionStore()
+ try await s.upsertDevice(id: "dev2", mac: nil, name: nil)
+ for ts in 100...102 {
+ _ = try await s.insert(waveform(ts), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 4)
+ }
+ // With a SHARED counter dev2's single row crosses the threshold and sweeps; because the delete is
+ // scoped to dev2 it would evict nothing from dev1 while zeroing dev1's budget.
+ _ = try await s.insert(waveform(200), deviceId: "dev2",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 4)
+ _ = try await s.insert(waveform(103), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 4)
+ let d1 = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000)
+ XCTAssertEqual(d1.map(\.ts), [103],
+ "dev1's own fourth row must trigger dev1's sweep — dev2 cannot spend its budget")
+ }
+
+ /// A batch with no waveform row must not sweep at all: a WHOOP 4.0 offload, or any non-v26 second,
+ /// never pays for the index scan and never evicts. Guards against banking the budget off other streams.
+ func testNoWaveformRowsMeansNoRetentionSweep() async throws {
+ let s = try await retentionStore()
+ _ = try await s.insert(waveform(100), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 5, ppgWaveformPruneEveryRows: 1)
+ // An HR-only batch banks nothing here, so the cap of 1 must NOT evict the waveform row above.
+ _ = try await s.insert(Streams(hr: [HRSample(ts: 200, bpm: 60)]), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 1)
+ let rows = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 1_000)
+ XCTAssertEqual(rows.map(\.ts), [100])
+ }
+
+ /// One strap's sweep must not evict ANOTHER strap's rows. Distinct from the budget test above, which
+ /// only proves the two devices bank separately: this pins the DELETE's own device scoping, and it is
+ /// the reason the older strap's rows sit BELOW the sweeping strap's cutoff. With `dev2` newer than the
+ /// cutoff (as in the budget test) the rows survive even a DELETE that has lost its `deviceId` filter,
+ /// so that arrangement cannot see the bug. Dropping the outer filter is silent cross-device data loss
+ /// on a multi-strap install, and nothing else in the suite catches it.
+ func testRetentionSweepDoesNotEvictAnotherDevicesRows() async throws {
+ let s = try await retentionStore()
+ try await s.upsertDevice(id: "dev2", mac: nil, name: nil)
+ for ts in [500, 501] {
+ _ = try await s.insert(waveform(ts), deviceId: "dev2",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 10, ppgWaveformPruneEveryRows: 50)
+ }
+ // dev1's rows are all NEWER than dev2's, so dev1's cutoff sits above every dev2 row.
+ for ts in 1_000...1_003 {
+ _ = try await s.insert(waveform(ts), deviceId: "dev1",
+ v18AuxRetentionRows: WhoopStore.v18AuxRetentionRows,
+ v18AuxPruneEveryRows: WhoopStore.v18AuxPruneEveryRows,
+ ppgWaveformRetentionRows: 1, ppgWaveformPruneEveryRows: 1)
+ }
+ let d1 = try await s.ppgWaveformSamples(deviceId: "dev1", from: 0, to: 10_000)
+ let d2 = try await s.ppgWaveformSamples(deviceId: "dev2", from: 0, to: 10_000)
+ XCTAssertEqual(d1.map(\.ts), [1_003], "dev1 is swept to its own cap")
+ XCTAssertEqual(d2.map(\.ts), [500, 501],
+ "dev2's older rows must survive dev1's sweep — the DELETE is per device")
+ }
+
+ /// The production cap must stay a WEEK-SCALE newest-N row count, not an age-based drop. Pins the
+ /// constant so a future "just make it 7 days" edit has to confront `ppgWaveformRetentionRows`' note.
+ func testProductionRetentionCapIsAWeekOfStrapSeconds() {
+ XCTAssertEqual(WhoopStore.ppgWaveformRetentionRows, 604_800)
+ XCTAssertEqual(WhoopStore.ppgWaveformPruneEveryRows, 10_000)
+ }
}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift
index 1ba9dcf511..97b624e0a9 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift
@@ -1,3 +1,6 @@
+#if canImport(Compression)
+// Every test here drives the zlib-backed raw outbox (pruneRaw / RawBatchMeta), which exists only
+// where Compression does. Same treatment RawOutboxTests gets, for the same reason.
import XCTest
import WhoopProtocol
@testable import WhoopStore
@@ -77,4 +80,6 @@ final class PruneTests: XCTestCase {
maxUnsyncedBytes: 1_000_000)
XCTAssertEqual(pruned, 0)
}
+
}
+#endif
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift
index 848c79a08b..0f7105cb05 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/RawOutboxTests.swift
@@ -1,3 +1,4 @@
+#if canImport(Compression)
import XCTest
import WhoopProtocol
@testable import WhoopStore
@@ -85,3 +86,4 @@ final class RawOutboxTests: XCTestCase {
XCTAssertEqual(gotZeros, zeros)
}
}
+#endif
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift
index 5737a694cd..a1c0ab2dc0 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift
@@ -68,6 +68,76 @@ final class ReadTests: XCTestCase {
XCTAssertEqual(scoped.count, 3)
}
+ /// Regression: motion can arrive after HR in a historical offload. The whole-pass watermark must move
+ /// for that gravity-only commit or the first partial (no-sleep) result becomes sticky.
+ func testAnalysisFingerprintMovesWhenSleepCriticalGravityArrivesAfterHr() async throws {
+ let store = try await seeded()
+ let hrOnly = try await store.analysisFingerprint()
+ _ = try await store.insert(
+ Streams(gravity: [GravitySample(ts: 400, x: 0, y: 0, z: 1)]),
+ deviceId: "dev1"
+ )
+ let withGravity = try await store.analysisFingerprint()
+ XCTAssertNotEqual(hrOnly, withGravity)
+ XCTAssertTrue(withGravity.contains("|g1|"))
+ }
+
+ /// #29: the per-DAY twin of the test above. `analysisFingerprint` answers "did anything change
+ /// anywhere"; the `analyzeRecent` reuse cache asks "did THIS night's scored input change", and keyed on
+ /// HR alone it could not see a channel that landed after HR — so a night scored from HR alone kept
+ /// being re-served with no HRV in it.
+ ///
+ /// Each stream is committed on its own here, because the failure mode IS independent commits: every one
+ /// of them must move the witness while `hrFingerprint` stays frozen.
+ func testDayStreamFingerprintMovesForEveryStreamThatLandsAfterHr() async throws {
+ let store = try await seeded()
+ let hrBefore = try await store.hrFingerprint(deviceId: "dev1", from: 0, to: 1000)
+ var previous = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000)
+
+ // Every arm of the witness gets an entry: an arm that silently reads a NEIGHBOURING table is
+ // valid SQL and invisible to a test that never commits its stream.
+ let commits: [(String, Streams)] = [
+ ("ppgHr", Streams(ppgHr: [PpgHrSample(ts: 401, bpm: 62, conf: 0.9)])),
+ ("rr", Streams(rr: [RRInterval(ts: 400, rrMs: 810)])),
+ ("resp", Streams(resp: [RespSample(ts: 400, raw: 12)])),
+ ("spo2", Streams(spo2: [SpO2Sample(ts: 400, red: 100, ir: 200)])),
+ ("gravity", Streams(gravity: [GravitySample(ts: 400, x: 0, y: 0, z: 1)])),
+ ("steps", Streams(steps: [StepSample(ts: 400, counter: 7)])),
+ ("skin", Streams(skinTemp: [SkinTempSample(ts: 400, raw: 1290)])),
+ ("sleepState", Streams(sleepState: [SleepStateSample(ts: 400, state: 2)])),
+ ("event", Streams(events: [WhoopEvent(ts: 410, kind: "WRIST_OFF", payload: [:])])),
+ ]
+ for (label, commit) in commits {
+ _ = try await store.insert(commit, deviceId: "dev1")
+ let now = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000)
+ XCTAssertNotEqual(previous, now, "a \(label) row landing after HR must move the day witness")
+ previous = now
+ }
+
+ // The HR witness never moved across ANY of those commits — the reason HR alone was not enough.
+ let hrAfter = try await store.hrFingerprint(deviceId: "dev1", from: 0, to: 1000)
+ XCTAssertEqual(hrBefore.count, hrAfter.count)
+ XCTAssertEqual(hrBefore.maxTs, hrAfter.maxTs)
+
+ // Reading twice with nothing committed in between must be identical, or the reuse cache would
+ // re-score every night on every pass and give back the drain it exists to close.
+ let idle = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000)
+ XCTAssertEqual(previous, idle)
+ }
+
+ /// The witness is scoped exactly like the reads it stands in for: another device's rows and rows
+ /// outside the night window must not move it, or every night would invalidate on any strap's traffic.
+ func testDayStreamFingerprintIsDeviceAndWindowScoped() async throws {
+ let store = try await seeded()
+ let base = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000)
+ _ = try await store.insert(Streams(rr: [RRInterval(ts: 400, rrMs: 810)]), deviceId: "other")
+ let afterOtherDevice = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000)
+ XCTAssertEqual(base, afterOtherDevice, "another device's R-R is not this owner's night")
+ _ = try await store.insert(Streams(rr: [RRInterval(ts: 5000, rrMs: 810)]), deviceId: "dev1")
+ let afterOutsideWindow = try await store.dayStreamFingerprint(deviceId: "dev1", from: 0, to: 1000)
+ XCTAssertEqual(base, afterOutsideWindow, "an R-R beat outside the window is not this night's input")
+ }
+
func testHrBucketsAveragePerBucketOrderedAndDeviceScoped() async throws {
let store = try await seeded()
// 200s buckets over dev1's ts 100/200/300 (bpm 60/61/62):
@@ -275,20 +345,127 @@ final class ReadTests: XCTestCase {
ppgWaveform: [PpgWaveformSample(ts: 400, samples: [1, 2, 3])],
v18Aux: [V18AuxSample(ts: 400, slotValues: [1, 2])]),
deviceId: "dev1")
- _ = try await store.insertRawImu(deviceId: "dev1",
- rows: [(ts: 400, cols: [Int16(1), Int16(2)])],
- retentionRows: 1000)
+ // The raw outbox is Compression-backed and absent off Darwin; the DECODED half of this count is
+ // platform-neutral and still worth asserting there, so only the raw seeding is gated.
+#if canImport(Compression)
try await store.enqueueRawBatch(
RawBatchMeta(batchId: "b1", deviceId: "dev1",
clockRef: ClockRef(device: 0, wall: 0), capturedAt: 1,
startTs: 0, endTs: 0, frameCount: 1, byteSize: 4),
frames: [[0xAA, 0x00, 0x01, 0x02]])
+#endif
let stats = try await store.storageStats()
// dev1: 3 hr + 2 rr + 1 event + 1 battery + 1 spo2 + 1 skinTemp + 1 resp + 1 gravity
- // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux + 1 rawImu = 17
- // other: 1 hr = 1 → 18 decoded rows across all 14 raw tables.
- XCTAssertEqual(stats.decodedRows, 18)
+ // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux = 16
+ // other: 1 hr = 1 → 17 decoded rows.
+ XCTAssertEqual(stats.decodedRows, 17)
+#if canImport(Compression)
XCTAssertEqual(stats.rawBatches, 1)
XCTAssertEqual(stats.rawBytes, 4)
+#endif
+ }
+
+ /// #1911: the footprint must name EVERY accumulating table, including the four the Apple probe used
+ /// to omit — `ppgHr`, `sleepState`, `ppgWaveform`, `v18Aux`. Those are the ones a row-count model
+ /// misprices worst (`ppgWaveformSample` is the only blob table), so leaving them out made the
+ /// footprint unable to attribute the bytes it was collected to explain.
+ ///
+ /// Pinned against the Android key set verbatim: a maintainer comparing meta.json across platforms is
+ /// comparing the same map, and a table added to one side without the other shows up here.
+ func testStorageRowCountsNamesEveryAccumulatingTable() async throws {
+ let store = try await WhoopStore.inMemory()
+ let counts = try await store.storageRowCounts()
+ let expected = ["hr", "rr", "events", "battery", "spo2", "skinTemp", "resp", "gravity",
+ "steps", "ppgHr", "sleepState", "ppgWaveform", "v18Aux"]
+ XCTAssertEqual(Set(counts.keys), Set(expected),
+ "key set must match Android's WhoopRepository.storageRowCounts exactly")
+ for k in expected {
+ XCTAssertEqual(counts[k], 0, "\(k) starts empty in a fresh store")
+ }
+ }
+
+ /// The total and the breakdown are derived from ONE list, so they cannot disagree about which tables
+ /// exist. Pinned because they used to be two hand-maintained lists, and the older one had already
+ /// drifted once — the comment on `storageStats` records it omitting six tables.
+ func testStorageStatsTotalAgreesWithTheBreakdown() async throws {
+ let store = try await seeded()
+ let counts = try await store.storageRowCounts()
+ let stats = try await store.storageStats()
+ XCTAssertEqual(stats.decodedRows, counts.values.reduce(0, +),
+ "the summed total must equal the per-table breakdown")
}
+
+
+ /// #1911: the byte estimate must MEASURE the blob rather than assume a fixed row width.
+ /// `ppgWaveformSample` is the only table here whose row size varies, and the one a per-second row
+ /// model misprices worst — a footprint that treats every row as fixed-width answers the wrong question
+ /// about exactly the table the question is usually about.
+ ///
+ /// Pinned by GROWING the blob rather than by comparing tables. My first attempt asserted that a blob
+ /// row estimates larger than an all-numeric row, which is false on a fixture: with a three-sample
+ /// blob `ppgWaveformSample` is three columns to `hrSample`'s four, so it estimates SMALLER. The blob
+ /// dominates in production (~48 bytes per v26 strap-second), not in a seed — so the invariant worth
+ /// pinning is that the blob's length reaches the estimate at all, which comparing two blob sizes
+ /// establishes and comparing two tables does not.
+ func testStorageByteEstimatesGrowWithBlobLength() async throws {
+ func estimate(sampleCount: Int) async throws -> Int {
+ let store = try await WhoopStore.inMemory()
+ let samples = [Int](repeating: 7, count: sampleCount)
+ _ = try await store.insert(Streams(ppgWaveform: [PpgWaveformSample(ts: 400, samples: samples)]),
+ deviceId: "dev1")
+ return try await store.storageByteEstimates()["ppgWaveform"] ?? 0
+ }
+ let small = try await estimate(sampleCount: 4) // ~8 bytes packed i16
+ let large = try await estimate(sampleCount: 200) // ~400 bytes packed i16
+ XCTAssertGreaterThan(small, 0, "the blob table must be estimated, not skipped")
+ XCTAssertGreaterThan(large, small + 300,
+ "a 400-byte blob must estimate far above an 8-byte one, or the blob is not measured")
+ }
+
+ /// An empty table is omitted rather than reported as zero bytes, matching the row counts: absent means
+ /// "nothing to attribute here", which is a different claim from a measured zero.
+ func testStorageByteEstimatesOmitEmptyTables() async throws {
+ let store = try await WhoopStore.inMemory()
+ let bytes = try await store.storageByteEstimates()
+ XCTAssertTrue(bytes.isEmpty, "a fresh store has no rows, so nothing to estimate")
+ }
+
+
+ /// Passing known counts must not change the answer — it only skips a second `COUNT(*)` pass. Pinned
+ /// because the whole point of the parameter is that it is an optimisation, and an optimisation that
+ /// quietly changes the number it optimises is worse than the scan it saves.
+ func testStorageByteEstimatesAreUnchangedByPassingKnownCounts() async throws {
+ let store = try await seeded()
+ let counts = try await store.storageRowCounts()
+ let computed = try await store.storageByteEstimates()
+ let reused = try await store.storageByteEstimates(rowCounts: counts)
+ XCTAssertEqual(computed, reused)
+ }
+
+ /// The raw-outbox read must agree with the aggregate it was split out of, or the probe's rawBytes
+ /// silently changed meaning when it stopped counting thirteen decoded tables to get one number.
+ ///
+ /// They share one implementation now rather than being two copies this test compares — `syncRead` is
+ /// `dbWriter.read` and nesting one inside another deadlocks, so a plain `db`-taking helper is the only
+ /// way to share it. The assertion stays as the guard on that wiring.
+ func testRawOutboxStatsMatchTheAggregate() async throws {
+ let store = try await seeded()
+ let stats = try await store.storageStats()
+ let outbox = try await store.rawOutboxStats()
+ XCTAssertEqual(outbox.batches, stats.rawBatches)
+ XCTAssertEqual(outbox.bytes, stats.rawBytes)
+ }
+
+
+ /// A negative sample limit must not become an unbounded scan. SQLite reads `LIMIT -1` as "no limit",
+ /// so an unchecked value would full-scan every table — the exact cost the sampling exists to avoid,
+ /// reached by passing a number that looks like it would sample less rather than more.
+ func testStorageByteEstimatesClampANegativeSampleLimit() async throws {
+ let store = try await seeded()
+ let normal = try await store.storageByteEstimates()
+ let negative = try await store.storageByteEstimates(sampleRows: -1)
+ XCTAssertEqual(negative.keys.sorted(), normal.keys.sorted())
+ for (k, v) in negative { XCTAssertGreaterThan(v, 0, "\(k) must still estimate") }
+ }
+
}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
index 4cb35172f1..743485063f 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
@@ -1,6 +1,6 @@
{
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.",
- "roomVersion": 34,
+ "roomVersion": 37,
"grdbMigrations": [
"v1",
"v2",
@@ -41,7 +41,10 @@
"v37-daily-avg-sdnn",
"v38-apple-step-hour",
"v39-ppg-burst-index",
- "v40-daily-skin-temp-absolute"
+ "v40-daily-skin-temp-absolute",
+ "v41-drop-raw-imu-sample",
+ "v42-daily-sleep-hr-only",
+ "v43-coach-messages"
],
"divergenceReasons": {
"android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android — the per-row upload flag belongs to an upload path that does not exist there — so it is dead width, not divergent data. Removing it needs a Room table rebuild.",
@@ -215,6 +218,55 @@
"androidColumnOrderDivergence": "battery-alter-append-order",
"indices": []
},
+ "coachMessage": {
+ "platform": "both",
+ "columns": [
+ {
+ "name": "id",
+ "affinity": "TEXT",
+ "notNull": false,
+ "default": null,
+ "android": {
+ "notNull": true
+ },
+ "divergence": "sqlite-text-pk-nullable"
+ },
+ {
+ "name": "role",
+ "affinity": "TEXT",
+ "notNull": true,
+ "default": null
+ },
+ {
+ "name": "text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "default": null
+ },
+ {
+ "name": "provider",
+ "affinity": "TEXT",
+ "notNull": true,
+ "default": null
+ },
+ {
+ "name": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "default": null
+ },
+ {
+ "name": "orderIndex",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "default": null
+ }
+ ],
+ "primaryKey": [
+ "id"
+ ],
+ "indices": []
+ },
"cursors": {
"platform": "ios_only",
"reason": "iOS offload/upload bookmarks (GRDB v2). Android keeps the equivalent bookmarks in SharedPreferences, not in Room.",
@@ -371,6 +423,16 @@
"affinity": "REAL",
"notNull": false,
"default": null
+ },
+ {
+ "name": "sleepHrOnly",
+ "affinity": "NUMERIC",
+ "notNull": false,
+ "default": null,
+ "android": {
+ "affinity": "INTEGER"
+ },
+ "divergence": "grdb-boolean-affinity"
}
],
"primaryKey": [
@@ -1259,34 +1321,6 @@
],
"indices": []
},
- "rawImuSample": {
- "platform": "both",
- "columns": [
- {
- "name": "deviceId",
- "affinity": "TEXT",
- "notNull": true,
- "default": null
- },
- {
- "name": "ts",
- "affinity": "INTEGER",
- "notNull": true,
- "default": null
- },
- {
- "name": "samples",
- "affinity": "BLOB",
- "notNull": true,
- "default": null
- }
- ],
- "primaryKey": [
- "deviceId",
- "ts"
- ],
- "indices": []
- },
"respSample": {
"platform": "both",
"columns": [
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift
index 39d780d2a2..38499cac8a 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift
@@ -127,6 +127,23 @@ final class ScoreInputProvenanceStoreTests: XCTestCase {
XCTAssertEqual(estimator, Vo2MaxEstimator.nes.rawValue)
}
+ func testMarkerRewriteSpansComputedOwnerNamespaces() async throws {
+ let store = try await WhoopStore.inMemory()
+ let day = "2026-07-24"
+ _ = try await store.upsertMetricSeries([.init(day: day, key: "day_cycle_onset_ts", value: 1)],
+ deviceId: "old-noop")
+ try await store.persistComputedScores(
+ dailyMetrics: [makeDaily(day: day, recovery: nil, strain: nil)], metricPoints: [], provenance: [],
+ deviceId: "active-noop", from: day, to: day,
+ replaceMetricKeys: ["day_cycle_onset_ts"],
+ additionalMetricPoints: [.init(deviceId: "old-noop",
+ point: .init(day: day, key: "day_cycle_onset_ts", value: 2))],
+ replaceMetricSourceIds: ["old-noop"])
+ let old = try await store.metricSeries(deviceId: "old-noop", key: "day_cycle_onset_ts",
+ from: day, to: day)
+ XCTAssertEqual(old.map(\.value), [2])
+ }
+
private func makeDaily(day: String, recovery: Double?, strain: Double?) -> DailyMetric {
DailyMetric(
day: day,
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift
index 4e9320e650..0c5f4b23df 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMergeTests.swift
@@ -10,7 +10,14 @@ final class SleepMergeTests: XCTestCase {
CachedSleepSession(startTs: start, endTs: end, efficiency: nil,
restingHr: nil, avgHrv: nil, stagesJSON: stages)
}
+ /// One hour of segments. Against the 8-hour spans these tests use this is a HOLED timeline — which
+ /// is deliberate for the presence-rule cases below (rank 1 behaves exactly as the old "has stages"
+ /// did against a stage-less day) and is the fixture the coverage cases contrast with `tiling`.
private let someStages = #"[{"start":0,"end":3600,"stage":"deep"}]"#
+ /// A single segment that covers `[start, end)` exactly — a night that accounts for its own span.
+ private func tiling(_ start: Int, _ end: Int) -> String {
+ #"[{"start":\#(start),"end":\#(end),"stage":"deep"}]"#
+ }
// Deterministic "local day" keyer for the tests (real callers pass their tz-aware keyer).
private let dayKey: (CachedSleepSession) -> String = { String($0.endTs / 86_400) }
@@ -78,6 +85,54 @@ final class SleepMergeTests: XCTestCase {
XCTAssertEqual(merged.map(\.startTs), [0, 86_400], #""[]" and blank JSON are not stages"#)
}
+ // Coverage rank: a stage timeline that covers only part of the span it claims (a device hypnogram
+ // assembled from records that arrived incomplete) outranks nothing, but not a whole night.
+
+ /// The case the presence-only rule could not express, and the reason this change exists: a HOLED
+ /// import used to win purely by being an import, blanking a computed night that covers itself.
+ func testHoledImportYieldsToFullyCoveredComputedNight() {
+ let comp = session(start: 0, end: 8 * 3600, stages: tiling(0, 8 * 3600))
+ let imp = session(start: 0, end: 8 * 3600, stages: someStages) // 1 h of segments over 8 h
+ let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey)
+ XCTAssertEqual(merged.map(\.startTs), [0])
+ XCTAssertEqual(merged.first?.stagesJSON, tiling(0, 8 * 3600),
+ "the night that covers its own span wins over a holed import")
+ }
+
+ /// …but partial data still beats none. A holed import must not lose to a stage-less computed day.
+ func testHoledImportStillBeatsStagelessComputed() {
+ let comp = session(start: 0, end: 8 * 3600) // no stages
+ let imp = session(start: 3600, end: 8 * 3600 + 1800, stages: someStages)
+ let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey)
+ XCTAssertEqual(merged.map(\.startTs), [3600], "some stages beat none, holed or not")
+ }
+
+ /// Equal rank keeps the imported-over-computed default — the rule only fires on a strict out-rank.
+ func testEquallyHoledSidesKeepImportedWins() {
+ let comp = session(start: 0, end: 8 * 3600, stages: someStages)
+ let imp = session(start: 3600, end: 8 * 3600 + 1800, stages: someStages)
+ let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey)
+ XCTAssertEqual(merged.map(\.startTs), [3600])
+ }
+
+ /// A fully-covered import is untouched by any of this.
+ func testFullyCoveredImportStillWins() {
+ let comp = session(start: 0, end: 8 * 3600, stages: tiling(0, 8 * 3600))
+ let imp = session(start: 3600, end: 3600 + 8 * 3600, stages: tiling(3600, 3600 + 8 * 3600))
+ let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey)
+ XCTAssertEqual(merged.map(\.startTs), [3600])
+ }
+
+ /// The imported minute-dict shape carries no timestamps, so it can never be judged holed — every
+ /// WHOOP/Apple/Health-Connect import keeps its existing precedence.
+ func testImportedMinuteDictIsNeverHoled() {
+ let comp = session(start: 0, end: 8 * 3600, stages: tiling(0, 8 * 3600))
+ let imp = session(start: 3600, end: 8 * 3600 + 1800,
+ stages: #"{"light":300,"deep":100,"rem":80,"awake":40}"#)
+ let merged = SleepMerge.merge(imported: [imp], computed: [comp], endDay: dayKey)
+ XCTAssertEqual(merged.map(\.startTs), [3600], "a dict-shaped import is not measurable, so it wins as before")
+ }
+
func testRichnessExceptionKeepsEverySessionOfWinningDay() {
// Day has computed main night (with stages) + computed nap (no stages); import is stage-less.
// The WHOLE computed day survives — #715's keep-every-session guarantee still holds.
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SourceIdentityTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SourceIdentityTests.swift
new file mode 100644
index 0000000000..91e9872068
--- /dev/null
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SourceIdentityTests.swift
@@ -0,0 +1,78 @@
+import XCTest
+@testable import WhoopStore
+
+/// Which device a live link's samples belong to (#1881) — the twin of the Kotlin `SourceIdentityTest`,
+/// asserting the same inputs and the same answers so the two platforms cannot drift.
+///
+/// The reported failure is the first case here: with an Oura ring active and a WHOOP merely paired, a
+/// WHOOP connection filed 770 `stepSample` and 770 `gravitySample` rows under the ring's id. A ring has no
+/// pedometer and no accelerometer, so 100% of those rows were misattributed — and because `hrSample` is
+/// written by both paths under one id, contamination there cannot be separated after the fact.
+final class SourceIdentityTests: XCTestCase {
+
+ private func row(_ id: String, _ brand: String, _ peripheralId: String?) -> PairedDevice {
+ PairedDevice(id: id, brand: brand, model: "m", peripheralId: peripheralId,
+ sourceKind: .liveBLE, capabilities: [.hr], status: .paired,
+ addedAt: 0, lastSeenAt: 0)
+ }
+
+ private lazy var ring = row("oura-abc", "Oura", "AA:BB:CC:DD:EE:01")
+ private lazy var strap = row("whoop-123", "WHOOP", "AA:BB:CC:DD:EE:02")
+
+ /// The reported bug: the ring is active, the STRAP connected, so the strap's id must win.
+ func testStrapConnectingWhileRingActiveIsAttributedToTheStrap() {
+ XCTAssertEqual(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:02",
+ rows: [ring, strap], currentId: "oura-abc"), "whoop-123")
+ }
+
+ /// The legacy single-WHOOP path, and the reason this returns nil rather than guessing: the seeded row
+ /// has not adopted an address yet. The coordinator adopts one on this same connect, so the NEXT connect
+ /// resolves — and until then the id stays exactly what it is today.
+ func testUnadoptedRowLeavesTheIdAlone() {
+ let legacy = row("my-whoop", "WHOOP", nil)
+ XCTAssertNil(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:02",
+ rows: [legacy], currentId: "my-whoop"))
+ }
+
+ /// No row carries this address — an unknown strap must never claim an existing device's id.
+ func testUnknownAddressLeavesTheIdAlone() {
+ XCTAssertNil(SourceIdentity.resolve(address: "FF:FF:FF:FF:FF:FF",
+ rows: [ring, strap], currentId: "oura-abc"))
+ }
+
+ /// The inverse of the bug. If a non-WHOOP row somehow matches, re-pointing the WHOOP path's id at it
+ /// would file strap samples under a ring — exactly what #1881 reports, arrived at from the other side.
+ func testNonWhoopMatchNeverClaimsTheStrapsSamples() {
+ XCTAssertNil(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:01",
+ rows: [ring, strap], currentId: "whoop-123"))
+ }
+
+ /// Already correct — the common path — must not write.
+ func testIdAlreadyCorrectResolvesToNothing() {
+ XCTAssertNil(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:02",
+ rows: [ring, strap], currentId: "whoop-123"))
+ }
+
+ /// Neither OS guarantees the case of a UUID string or a MAC, and old rows may carry either.
+ func testAddressMatchingIsCaseInsensitive() {
+ XCTAssertEqual(SourceIdentity.resolve(address: "aa:bb:cc:dd:ee:02",
+ rows: [ring, strap], currentId: "oura-abc"), "whoop-123")
+ }
+
+ /// A blank address is a missing address, not a wildcard that matches a blank stored value.
+ func testBlankAddressLeavesTheIdAlone() {
+ let blank = row("whoop-blank", "WHOOP", "")
+ XCTAssertNil(SourceIdentity.resolve(address: "", rows: [blank], currentId: "oura-abc"))
+ XCTAssertNil(SourceIdentity.resolve(address: nil, rows: [blank], currentId: "oura-abc"))
+ // Whitespace is blank on both platforms — Kotlin's `isNullOrBlank` set the contract.
+ let spaces = row("whoop-spaces", "WHOOP", " ")
+ XCTAssertNil(SourceIdentity.resolve(address: " ", rows: [spaces], currentId: "oura-abc"))
+ }
+
+ /// The legacy id is a WHOOP by id even though its brand was never guaranteed.
+ func testLegacyRowCountsAsWhoop() {
+ let legacy = row("my-whoop", "", "AA:BB:CC:DD:EE:03")
+ XCTAssertEqual(SourceIdentity.resolve(address: "AA:BB:CC:DD:EE:03",
+ rows: [legacy], currentId: "oura-abc"), "my-whoop")
+ }
+}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift
index 018ef3218f..1777250208 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/StandardHRMappingTests.swift
@@ -17,6 +17,47 @@ final class StandardHRMappingTests: XCTestCase {
XCTAssertTrue(s.rr.isEmpty)
}
+ func testStandardHRContactIsMappedAsAStableEvent() throws {
+ let s = StandardHRMapping.samples(
+ fromHR: 72,
+ rr: [],
+ contact: .supportedDetected,
+ at: 1_750_000_000
+ )
+ XCTAssertEqual(s.events, [
+ WhoopEvent(
+ ts: 1_750_000_000,
+ kind: StandardHRMapping.contactEventKind,
+ payload: ["contact": .string("supported_detected")]
+ )
+ ])
+ }
+
+ func testLegacyMappingDoesNotFabricateContact() throws {
+ let s = StandardHRMapping.samples(fromHR: 72, rr: [], at: 1_750_000_000)
+ XCTAssertTrue(s.events.isEmpty)
+ }
+
+ func testContactSurvivesInsertAndReadWhileLegacyRowsStayAbsent() async throws {
+ let store = try await WhoopStore.inMemory()
+ try await store.upsertDevice(id: "standard-strap", mac: nil, name: nil)
+ _ = try await store.insert(
+ StandardHRMapping.samples(fromHR: 72, rr: [], contact: .supportedNotDetected, at: 100),
+ deviceId: "standard-strap"
+ )
+ _ = try await store.insert(
+ StandardHRMapping.samples(fromHR: 73, rr: [], at: 101),
+ deviceId: "standard-strap"
+ )
+
+ let contacts = try await store.standardHRContacts(
+ deviceId: "standard-strap", from: 0, to: 200, limit: 10
+ )
+ XCTAssertEqual(contacts, [
+ StandardHRContactSample(ts: 100, contact: .supportedNotDetected)
+ ])
+ }
+
func testOnlyHRandRRStreamsArePopulated() throws {
// A chest strap reports nothing else — every other stream must stay empty.
let s = StandardHRMapping.samples(fromHR: 88, rr: [700], at: 42)
@@ -29,4 +70,45 @@ final class StandardHRMappingTests: XCTestCase {
XCTAssertTrue(s.events.isEmpty)
XCTAssertTrue(s.battery.isEmpty)
}
+
+ func testContactSampleParsesPayloadJSONAndDoesNotTreatParseFailureAsAbsence() throws {
+ let detected = try StandardHRMapping.contactSample(
+ ts: 100,
+ payloadJSON: #"{"extra":true,"contact":"supported_detected"}"#
+ )
+ XCTAssertEqual(detected, StandardHRContactSample(ts: 100, contact: .supportedDetected))
+
+ XCTAssertThrowsError(try StandardHRMapping.contactSample(ts: 101, payloadJSON: "not-json"))
+ XCTAssertThrowsError(try StandardHRMapping.contactSample(ts: 102, payloadJSON: "{}"))
+ }
+
+ /// Contact is a state, not a measurement. A ~1 Hz stream that recorded every reading wrote ~86,400
+ /// rows a day per device to say the same thing, for a read side that only ever needed the changes.
+ ///
+ /// Asserted over a SEQUENCE rather than on the predicate alone: the number that matters is how many
+ /// rows a run of readings produces, and a predicate test would pass just as happily if the caller
+ /// stopped consulting it. Kotlin twin: "a run of identical readings records one row, and each change
+ /// records one more".
+ func testARunOfIdenticalReadingsRecordsOneRowAndEachChangeRecordsOneMore() {
+ let readings = Array(repeating: StandardHRContact.supportedDetected, count: 600)
+ + Array(repeating: StandardHRContact.supportedNotDetected, count: 300)
+ + Array(repeating: StandardHRContact.supportedDetected, count: 600)
+
+ var previous: StandardHRContact?
+ var recorded = 0
+ for c in readings where StandardHRMapping.shouldRecordContact(previous: previous, current: c) {
+ recorded += 1
+ previous = c
+ }
+ // 1500 readings — a full session — become three rows: the opening state and two transitions.
+ XCTAssertEqual(recorded, 3)
+ }
+
+ /// The first reading always records, so a session never opens with an assumed state.
+ func testTheFirstReadingAlwaysRecords() {
+ XCTAssertTrue(StandardHRMapping.shouldRecordContact(previous: nil, current: .unsupported))
+ XCTAssertTrue(StandardHRMapping.shouldRecordContact(previous: nil, current: .supportedDetected))
+ XCTAssertFalse(StandardHRMapping.shouldRecordContact(previous: .supportedDetected,
+ current: .supportedDetected))
+ }
}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift
index deb4ae3a73..e5da67ae10 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/StepSampleTests.swift
@@ -32,6 +32,56 @@ final class StepSampleTests: XCTestCase {
XCTAssertEqual(n2, 2)
}
+ func testStepRevisionSignatureIsStableAsActiveWindowAdvances() async throws {
+ let store = try await WhoopStore.inMemory()
+ let day = 1_780_876_800
+ _ = try await store.insert(Streams(steps: [
+ StepSample(ts: day + 100, counter: 1),
+ ]), deviceId: "strap")
+
+ let first = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: day, to: day + 1_000)
+ let later = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: day, to: day + 2_000)
+
+ XCTAssertEqual(first, later)
+ }
+
+ func testStepRevisionOnlyInvalidatesTouchedUtcDays() async throws {
+ let store = try await WhoopStore.inMemory()
+ let day = 1_780_876_800
+ _ = try await store.insert(Streams(steps: [
+ StepSample(ts: day + 100, counter: 1),
+ ]), deviceId: "strap")
+ let firstDayBefore = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: day, to: day + 86_400)
+ let secondDayBefore = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: day + 86_400, to: day + 172_800)
+
+ _ = try await store.insert(Streams(steps: [
+ StepSample(ts: day + 86_500, counter: 2),
+ ]), deviceId: "strap")
+ let firstDayAfter = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: day, to: day + 86_400)
+ let secondDayAfter = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: day + 86_400, to: day + 172_800)
+
+ XCTAssertEqual(firstDayBefore, firstDayAfter)
+ XCTAssertNotEqual(secondDayBefore, secondDayAfter)
+ }
+
+ func testDuplicateStepInsertDoesNotInvalidateRevision() async throws {
+ let store = try await WhoopStore.inMemory()
+ let sample = StepSample(ts: 1_780_916_150, counter: 50)
+ _ = try await store.insert(Streams(steps: [sample]), deviceId: "strap")
+ let before = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: sample.ts - 1, to: sample.ts + 1)
+ _ = try await store.insert(Streams(steps: [sample]), deviceId: "strap")
+ let after = await store.stepDataRevisionSignature(
+ deviceId: "strap", from: sample.ts - 1, to: sample.ts + 1)
+ XCTAssertEqual(before, after)
+ }
+
/// v19 migration: the @63 activity-class column exists on stepSample (#316).
func testV19AddsActivityClassColumn() async throws {
let store = try await WhoopStore.inMemory()
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift
new file mode 100644
index 0000000000..4c8b0d68ce
--- /dev/null
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/WhoopSerialIdentityTests.swift
@@ -0,0 +1,66 @@
+import XCTest
+@testable import WhoopStore
+
+/// The REFUSALS matter more than the composition: adoption migrates every device-scoped row onto the id
+/// this returns, so a junk serial must yield nil and leave the strap on its existing id rather than move a
+/// history onto a garbage key (#1303). Kotlin twin: `WhoopSerialIdentityTest`.
+final class WhoopSerialIdentityTests: XCTestCase {
+
+ func testComposesTheSerialId() {
+ XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5AG12345678"), "whoop-5AG12345678")
+ }
+
+ func testUpperCasesSoOneStrapCannotBecomeTwoIds() {
+ XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: "5ag12345678"),
+ WhoopSerialIdentity.adoptedId(serial: "5AG12345678"))
+ }
+
+ func testTrimsSurroundingWhitespaceFromTheGattString() {
+ XCTAssertEqual(WhoopSerialIdentity.adoptedId(serial: " 5AG12345678\n"), "whoop-5AG12345678")
+ }
+
+ func testRefusesBlankOrMissing() {
+ XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: nil))
+ XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: ""))
+ XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: " \n "))
+ }
+
+ func testRefusesATruncatedRead() {
+ // A partial GATT response must not become an id: it would collide across straps.
+ XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "5AG"))
+ }
+
+ func testRefusesADescriptiveStringThatIsNotASerial() {
+ // Some peripherals answer DIS with prose. Never let that become a device id.
+ XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "Not Available"))
+ XCTAssertNil(WhoopSerialIdentity.adoptedId(serial: "serial#1234"))
+ }
+
+ func testAlreadyAdoptedIsTheReconnectEarlyOut() {
+ XCTAssertTrue(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: "5AG12345678"))
+ XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-ABCDEF-0123", serial: "5AG12345678"))
+ // An unusable serial is never "already adopted" — otherwise a junk read would silently suppress a
+ // later good one.
+ XCTAssertFalse(WhoopSerialIdentity.isAlreadyAdopted(id: "whoop-5AG12345678", serial: " "))
+ }
+
+ /// The guard that makes this safe to ship before #1304. Every existing single-WHOOP install is on the
+ /// legacy `my-whoop` seed, ~47 code paths read that literal directly, and `WhoopBleClient` never
+ /// reassigns its deviceId on the single-WHOOP path — so adopting it would migrate the history onto
+ /// `whoop-` while new samples kept landing under `my-whoop`. A split history reads as data loss.
+ func testRefusesToAdoptTheLegacySingleWhoopSeed() {
+ XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "my-whoop"))
+ // A provisional pairing id IS adoptable — that is the multi-strap case this ships for.
+ XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-6B9F2C11-0000-4000-8000-0000000000AA"))
+ // An already-adopted serial id stays adoptable; the equality check upstream stops the re-migration.
+ XCTAssertTrue(WhoopSerialIdentity.mayAdopt(currentId: "whoop-5AG12345678"))
+ // Another brand's id is never touched by the WHOOP path.
+ XCTAssertFalse(WhoopSerialIdentity.mayAdopt(currentId: "oura-2H3B2405003655"))
+ }
+
+ func testLogSafeNeverLeaksTheFullSerial() {
+ XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: "5AG12345678"), "5AG…")
+ XCTAssertEqual(WhoopSerialIdentity.logSafe(serial: nil), "?")
+ XCTAssertFalse(WhoopSerialIdentity.logSafe(serial: "5AG12345678").contains("12345678"))
+ }
+}
diff --git a/README.md b/README.md
index 4226c6019a..d4749ec2f9 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@
👽 Reddit ·
Features ·
Protocol ·
+ Raw data capture ·
@@ -95,7 +96,7 @@ Pre-built apps you can run right now:
Prefer to build it yourself? See [`docs/BUILD.md`](docs/BUILD.md).
-Everything runs **offline**. The only feature that ever uses the network is the optional **AI Coach**, and only with your own API key.
+Everything runs **offline by default** — nothing about you leaves the device unless you switch on a feature that sends it. NOOP makes only three kinds of network request, all described in [`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md): the optional **AI Coach** (off until you add your own API key), a once-a-day check for a newer release, which sends nothing about you and never installs anything, and Android's default-off Experimental one-way **push** to an endpoint you own. Turn the check off in Settings → About and it makes no request at all. NOOP operates no server, account, or telemetry service.
---
@@ -187,7 +188,7 @@ shared cross-platform code.
| **Data Sources** | One-tap import of a WHOOP CSV export, an Apple Health export, or a **nutrition CSV** (Cronometer / MacroFactor), plus live-strap status. "Bring your history in once, then it's yours." |
| **Notifications** | Configure local notifications and thresholds (`Strand/Data/NotificationSettingsStore.swift`). |
| **Automations** | Turn the strap's physical inputs and live biometrics into Mac actions — all on-device (see below). |
-| **Coach** | An optional **AI Coach** you can ask about your data in plain language. It's the one feature that can ever use the network: off until you add your own key — Anthropic, OpenAI, or any OpenAI-compatible endpoint including a local/self-hosted model (Ollama, LM Studio) — and it sends only a short text summary of recent metrics plus your question, never raw streams or identifiers. With a local model the conversation never leaves your machine. Available on macOS, Android, and iOS. See [`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md). |
+| **Coach** | An optional **AI Coach** you can ask about your data in plain language. It is off until you add your own key — Anthropic, OpenAI, or any OpenAI-compatible endpoint including a local/self-hosted model (Ollama, LM Studio) — and it sends only a short text summary of recent metrics plus your question, never raw streams or identifiers. With a local model the conversation never leaves your machine. Available on macOS, Android, and iOS. See [`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md). |
| **Settings** | Profile, preferences, **step calibration** (tune the stride/step estimate to your own walking), unit choices, the in-app **What's new** changelog, and an opt-in **Experimental** section (WHOOP 5/MG protocol probes). On **iOS**, also **Export for Shortcuts** — a HealthKit-free path that hands your metrics to Apple Health via the Shortcuts app. |
There is also a **menu-bar extra** (`Strand/MenuBar/MenuBarContent.swift`) with a
@@ -521,9 +522,17 @@ Every arrow stays on your machine.
## Privacy
-**Offline by design.** NOOP has no server, no telemetry, and no account. Your
-strap data, imports, and computed metrics live in a local SQLite database on your
-device and never leave it.
+**Offline by default.** NOOP has no server, no telemetry, and no account. Your strap data, imports,
+and computed metrics live in a local SQLite database on your device. They leave only through an
+export or optional network feature you deliberately configure, including Android's default-off
+Experimental one-way push to your own endpoint; see
+[`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md).
+
+The app makes two kinds of network request, neither carrying anything about you: the optional
+**AI Coach** (off until you add your own key), and a once-a-day read of the latest release number so a
+sideloaded install can tell you an update exists — on by default, switchable off in Settings → About,
+and it never installs anything. Both are detailed in
+[`docs/PRIVACY_SECURITY.md`](docs/PRIVACY_SECURITY.md).
---
diff --git a/SECURITY.md b/SECURITY.md
index be3eeaa13d..13a74a9559 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,18 +2,25 @@
## Scope
-NOOP is a fully offline, on-device app. It has no servers, no accounts, and no
-cloud sync, so the usual web attack surface does not apply. What remains is local:
+NOOP is an offline-by-default, on-device app. It operates no servers, accounts,
+cloud sync, or telemetry, so the usual hosted-service attack surface does not apply.
+What remains is local or explicitly user-configured:
- **Bluetooth Low Energy** — the link to your WHOOP strap.
- **Local SQLite database** — every reading is stored on your own device.
- **File imports** — WHOOP CSV exports and Apple Health ZIP files you choose to open.
-- **AI Coach (optional, off by default)** — the only feature that makes a network
- request, and only with an API key you supply yourself, to the provider you choose.
-
-A useful security report is one that lets data leave the device when it shouldn't,
+- **AI Coach (optional, off by default)** — sends a compact summary only when asked,
+ with credentials you supply, to the provider you choose.
+- **Self-hosted push (Experimental, Android, off by default)** — sends versioned batches
+ one way to a user-owned endpoint after offload; it never reads records back and never
+ participates in strap sync. A receiver can only narrow the fixed stream registry it accepts.
+ See [`docs/PUSH_PROTOCOL.md`](docs/PUSH_PROTOCOL.md).
+- **Oura history import (compile-time optional)** — pulls from Oura using a developer
+ application and grant supplied by the user; it is absent from default builds.
+
+A useful security report is one that lets data leave the device without the corresponding opt-in,
lets a malicious strap or crafted import file corrupt the database or run code, or
-otherwise breaks the offline, local-only guarantee the app makes.
+otherwise breaks the offline-by-default or one-way-export guarantees the app makes.
## Reporting a vulnerability
diff --git a/Strand/AI/AICoach.swift b/Strand/AI/AICoach.swift
index 0677802d8d..8d91153709 100644
--- a/Strand/AI/AICoach.swift
+++ b/Strand/AI/AICoach.swift
@@ -156,8 +156,20 @@ final class AICoachEngine: ObservableObject {
// Published state the UI binds to.
@Published var messages: [ChatMessage] = []
+
+ /// Local day the current transcript was last written on; nil while it is empty. Drives the day
+ /// boundary in `send` — see `isStaleConversation`. Kotlin twin: `CoachViewModel.conversationDay`.
+ private var conversationDay: Int?
@Published var sending = false
@Published var errorText: String?
+
+ /// #1862: a question handed over by the Today Coach launcher sheet, for `CoachView` to send on appear.
+ ///
+ /// The launcher owns no send, stream, error or consent surface of its own — duplicating those is how a
+ /// second chat UI drifts from the first. It collects a question and hands it here; the Coach screen,
+ /// which already has all of that, consumes it exactly once and clears it. Nil is the normal state, and
+ /// setting it performs NO network work by itself.
+ @Published var pendingPrompt: String?
@Published var provider: AIProvider {
didSet {
guard provider != oldValue else { return }
@@ -202,6 +214,14 @@ final class AICoachEngine: ObservableObject {
didSet { UserDefaults.standard.set(includeOnDeviceSignals, forKey: Self.onDeviceSignalsKey) }
}
+ /// K11: THIRD opt-in — send a chart image alongside the text when using Gemini's multimodal
+ /// API. OFF by default and gated behind `dataConsent` too. Only active when the provider is
+ /// Gemini (the only provider with multimodal support in the app). When on, the Coach composer
+ /// shows an "Attach chart" toggle; the rendered chart is sent as inline_data to Gemini.
+ @Published var multimodalChartEnabled: Bool {
+ didSet { UserDefaults.standard.set(multimodalChartEnabled, forKey: Self.multimodalChartKey) }
+ }
+
private let repo: Repository
private let session: URLSession
@@ -210,6 +230,7 @@ final class AICoachEngine: ObservableObject {
private static let consentKey = "ai.dataConsent"
private static let customConnectedKey = "ai.customConnected"
private static let onDeviceSignalsKey = "ai.includeOnDeviceSignals"
+ private static let multimodalChartKey = "ai.multimodalChartEnabled"
/// UserDefaults key holding the user's EDITED system prompt. Absent (or blank) means "use the
/// built-in default". Small text key, never a secret, so plain UserDefaults is fine. Read FRESH
/// per request (see `systemPrompt`) so an edit takes effect on the very next message.
@@ -221,8 +242,10 @@ final class AICoachEngine: ObservableObject {
static let defaultSystemPrompt = """
You are an elite, supportive recovery and performance coach with a real training methodology. \
You may be given a summary of the user's own wearable data (charge 0-100, effort 0-100, rest 0-100, \
- HRV, resting heart rate) and recent workouts. Charge is the daily recovery/readiness score, effort \
- is the daily cardiovascular load score, and rest is the nightly sleep-quality score. \
+ sleep duration and its deep/REM/light breakdown, sleep efficiency, HRV, resting heart rate) and \
+ recent workouts. Charge is the daily recovery/readiness score, effort is the daily cardiovascular \
+ load score, and rest is the nightly sleep-quality score. A dash in the data means that value was \
+ NOT MEASURED that day — say so rather than treating it as a zero. \
Coach using autoregulation:
• Readiness → prescription: charge 67-100 = green light to build/push, higher effort is fine; \
34-66 = maintain, quality over volume, keep it controlled; 0-33 = active recovery only \
@@ -277,6 +300,39 @@ final class AICoachEngine: ObservableObject {
objectWillChange.send()
}
+ /// Contextual suggestion chips for the composer, derived from today's bands via `CoachSuggestions`.
+ /// Reads only on-device `repo.days`; pure, byte-identical to the Android twin. Returns the stable
+ /// generic fallback when there is no usable data for today.
+ var suggestions: [String] { CoachSuggestions.suggestions(for: repo.days.last, recent: repo.days) }
+
+ /// K7: Follow-up suggestion chips shown after each assistant reply. These are generic
+ /// conversational follow-ups (not data-derived) so the user can dig deeper without typing.
+ /// Byte-identical to the Android twin's `followUpSuggestions`.
+ static let followUpSuggestions: [String] = [
+ "Tell me more about that",
+ "What should I do next?",
+ "How does today compare to this week?",
+ "Give me a specific action plan",
+ ]
+
+ /// K12: Rough token estimate for the next send, based on the current draft + context size.
+ /// Uses the standard ~4 chars/token heuristic. This is an estimate only — actual token counts
+ /// vary by tokenizer. Returns nil when the engine isn't configured (no context to estimate).
+ func estimatedTokens(forDraft draft: String) -> Int? {
+ guard isConfigured else { return nil }
+ // Estimate the context size: system prompt + data context (rough — we don't build the
+ // full context here to avoid a DB read on every keystroke). Use the last known context
+ // size or a reasonable default.
+ let systemPromptTokens = systemPrompt.count / 4
+ // The data context is typically ~2000-4000 chars depending on the user's data.
+ // Use a conservative estimate of 3000 chars (750 tokens) when consent is on.
+ let contextTokens = dataConsent ? 750 : 50
+ // History tokens: sum of all message texts in the windowed history.
+ let historyTokens = windowedMessages().reduce(0) { $0 + $1.text.count / 4 }
+ let draftTokens = draft.count / 4
+ return systemPromptTokens + contextTokens + historyTokens + draftTokens
+ }
+
/// Used in place of the metrics context when the user has NOT granted data access.
private let noConsentNote = """
NOTE: The user has not granted access to their biometric data. Coach generally and encourage \
@@ -312,6 +368,7 @@ final class AICoachEngine: ObservableObject {
self.customAuthHeader = AIProvider.customAuthHeader
self.customConnected = UserDefaults.standard.bool(forKey: Self.customConnectedKey)
self.includeOnDeviceSignals = UserDefaults.standard.bool(forKey: Self.onDeviceSignalsKey)
+ self.multimodalChartEnabled = UserDefaults.standard.bool(forKey: Self.multimodalChartKey)
}
// MARK: Key management
@@ -361,6 +418,12 @@ final class AICoachEngine: ObservableObject {
func disconnect() {
AIKeyStore.clear()
customConnected = false
+ // Retire the transcript with the connection. Kotlin has done this since the method existed
+ // (CoachViewModel.disconnect) and this side never did, so returning to the setup screen on Apple
+ // left the whole conversation sitting behind it — including whatever the user had told a coach
+ // they were in the middle of disconnecting from.
+ messages = []
+ conversationDay = nil
objectWillChange.send()
}
@@ -384,6 +447,11 @@ final class AICoachEngine: ObservableObject {
/// Forget the stored key.
func clearKey() {
AIKeyStore.clear()
+ // Same reasoning as `disconnect`: clearing the key returns the user to the setup screen, and
+ // Kotlin empties the transcript when it does. Leaving it meant a "clear my key" on Apple removed
+ // the credential and kept the conversation.
+ messages = []
+ conversationDay = nil
objectWillChange.send()
}
@@ -474,6 +542,77 @@ final class AICoachEngine: ObservableObject {
}
}
+ // MARK: - K2: persisted conversation history
+
+ /// Guards `loadPersistedMessagesIfNeeded()` so it only ever runs once per app launch, even if the
+ /// Coach screen's `.task` re-fires (e.g. a tab re-select).
+ private var didLoadPersistedMessages = false
+
+ /// Load the conversation persisted by a PRIOR launch (PRD-K2), so relaunching doesn't lose it.
+ /// Called from the Coach screen's `.task` (mirroring `startBriefIfNeeded`) rather than `init`,
+ /// which is synchronous and runs for every screen the app builds, not just Coach. Best-effort: a
+ /// store failure just leaves the transcript empty, matching pre-K2 behaviour — never crashes.
+ func loadPersistedMessagesIfNeeded() async {
+ guard !didLoadPersistedMessages else { return }
+ didLoadPersistedMessages = true
+ guard messages.isEmpty, let store = await repo.storeHandle() else { return }
+ guard let rows = try? await store.coachMessages(), !rows.isEmpty else { return }
+ messages = rows
+ .sorted { $0.orderIndex < $1.orderIndex }
+ .map { ChatMessage(id: UUID(uuidString: $0.id) ?? UUID(),
+ role: ChatMessage.Role(rawValue: $0.role) ?? .user,
+ text: $0.text) }
+ }
+
+ /// Replace the ENTIRE persisted conversation with the current in-memory `messages`. Called once
+ /// per completed send/brief (not per streamed chunk) so a streamed reply's several in-place text
+ /// mutations don't hammer the store. Fire-and-forget; a store failure never blocks the UI — the
+ /// in-memory transcript (what the user sees) is unaffected either way.
+ private func persistMessages() {
+ let snapshot = messages
+ let providerId = provider.rawValue
+ Task {
+ guard let store = await repo.storeHandle() else { return }
+ let rows = snapshot.enumerated().map { index, m in
+ CoachMessageRow(id: m.id.uuidString, role: m.role.rawValue, text: m.text,
+ provider: providerId, createdAt: Int(Date().timeIntervalSince1970),
+ orderIndex: index)
+ }
+ try? await store.replaceCoachMessages(rows)
+ }
+ }
+
+ /// The Coach toolbar's "Clear conversation" action: wipes both the in-memory transcript and the
+ /// persisted table. Fire-and-forget on the store side; the in-memory clear is immediate.
+ func clearConversation() {
+ messages = []
+ droppedSummary = nil // K13: reset the summary cache on clear
+ droppedSummaryKey = []
+ Task { try? await repo.storeHandle()?.clearCoachMessages() }
+ }
+
+ /// K5: surface a brief generated by the SCHEDULED morning-brief notification as the first Coach
+ /// message, with no network call — called once when the app opens via a tap on that notification.
+ /// No-op if a conversation already exists, so it never duplicates into an active chat.
+ func surfaceScheduledBrief(_ text: String) {
+ guard messages.isEmpty else { return }
+ appendMessage(ChatMessage(role: .assistant, text: "Today's brief\n\n" + text))
+ persistMessages()
+ }
+
+ /// K5: append an explicitly-generated brief (the Coach settings "Generate now" button) as a new
+ /// assistant message, unconditionally — unlike `surfaceScheduledBrief`, this always appends so a
+ /// mid-conversation tap still shows the fresh brief.
+ func appendGeneratedBrief(_ text: String) {
+ appendMessage(ChatMessage(role: .assistant, text: "Today's brief\n\n" + text))
+ persistMessages()
+ }
+
+ /// K11: An optional chart image (base64-encoded PNG) to send with the next user message.
+ /// Set by the composer's "Attach chart" toggle when multimodal is enabled and the provider
+ /// is Gemini. Consumed (cleared) on the next send. nil when no image is attached.
+ @Published var pendingChartImage: String?
+
/// Send a question: append it, build the metrics context, call the chosen provider with the
/// system prompt + context + running history, parse the reply, append it. Never throws/crashes;
/// failures land in `errorText`.
@@ -482,59 +621,189 @@ final class AICoachEngine: ObservableObject {
guard !trimmed.isEmpty else { errorText = AICoachError.emptyQuestion.errorDescription; return }
guard let key = resolvedKey else { errorText = AICoachError.noKey.errorDescription; return }
+ // A transcript from an earlier local day is retired before the new turn is appended (#1542,
+ // Kotlin twin merged first). `messages` outlives a night — the engine is held for the app's
+ // lifetime — so without this the coach answers TODAY's question inside YESTERDAY's
+ // conversation. The DATA was never stale: buildFullContext() re-reads on every send. It is the
+ // assistant's own earlier turns stating yesterday's figures, and the model staying consistent
+ // with them, which reads as "the coach only talks about my imported data" after a night of
+ // fresh strap data.
+ //
+ // Placed AFTER the guards on purpose: a send that never happens must not wipe a transcript.
+ let today = Self.localEpochDay()
+ if Self.isStaleConversation(lastEpochDay: conversationDay, todayEpochDay: today) {
+ messages = []
+ }
+ conversationDay = today
+
errorText = nil
appendMessage(ChatMessage(role: .user, text: trimmed))
sending = true
- defer { sending = false }
+ // K2: persist once the turn is fully settled (success, mid-stream error, or empty-stream
+ // removal) — not per streamed chunk, so a long reply doesn't hammer the store.
+ defer { sending = false; persistMessages() }
// Build the data context once and prepend it to the FIRST user turn we send. We send the
// full running history so follow-ups stay coherent; the context only needs to ride the
// earliest user message.
// Include the user's data ONLY with explicit consent; otherwise send a note instead of numbers.
let context = dataConsent ? await buildFullContext() : noConsentNote
- let wire = wireMessages(context: context)
+ // K13: if the conversation overflows the sliding window, summarize the dropped middle so
+ // the model retains context continuity. Best-effort; failure degrades to the old gap.
+ await summarizeDroppedMiddleIfNeeded(key: key)
+ var wire = wireMessages(context: context)
+
+ // K11: If a chart image is pending and the provider is Gemini, attach it to the last
+ // user turn as inline_data. Non-Gemini providers can't accept images, so the image is
+ // silently dropped (the text question still goes through). Cleared after consumption.
+ let imageBase64 = pendingChartImage
+ pendingChartImage = nil
+
+ // K1: Stream the reply. Append a placeholder assistant message, then mutate its text as
+ // chunks arrive by replacing the last element in `messages`. The transcript re-renders on
+ // each update (SwiftUI binds to `messages`). On error mid-stream, keep the partial text and
+ // append a "(stream interrupted)" marker — never a crash.
+ let placeholder = ChatMessage(role: .assistant, text: "")
+ appendMessage(placeholder)
+ var accumulated = ""
do {
- let reply = try await callProvider(key: key, messages: wire)
- let clean = reply.trimmingCharacters(in: .whitespacesAndNewlines)
- appendMessage(ChatMessage(role: .assistant, text: clean.isEmpty ? "(no reply)" : clean))
+ try await streamProvider(key: key, messages: wire, inlineImage: imageBase64) { delta in
+ accumulated += delta
+ // Replace the last message's text with the accumulated stream so far.
+ if let lastIdx = self.messages.indices.last,
+ self.messages[lastIdx].role == .assistant {
+ self.messages[lastIdx] = ChatMessage(
+ id: placeholder.id, role: .assistant, text: accumulated
+ )
+ }
+ }
+ // Finalize: trim whitespace. If the stream produced nothing, show "(no reply)".
+ let clean = accumulated.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages[lastIdx] = ChatMessage(
+ id: placeholder.id, role: .assistant,
+ text: clean.isEmpty ? "(no reply)" : clean
+ )
+ }
} catch let e as AICoachError {
+ // Mid-stream error: keep the partial text + an interrupted marker (PRD K1 acceptance).
+ let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !partial.isEmpty, let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages[lastIdx] = ChatMessage(
+ id: placeholder.id, role: .assistant,
+ text: partial + "\n\n*(stream interrupted)*"
+ )
+ } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ // No text received at all — remove the empty placeholder.
+ messages.remove(at: lastIdx)
+ }
errorText = e.errorDescription
} catch {
+ let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !partial.isEmpty, let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages[lastIdx] = ChatMessage(
+ id: placeholder.id, role: .assistant,
+ text: partial + "\n\n*(stream interrupted)*"
+ )
+ } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages.remove(at: lastIdx)
+ }
errorText = AICoachError.network(error.localizedDescription).errorDescription
}
}
/// Proactively generate "Today's brief" the first time the Coach opens, readiness + a training
/// prescription + one recovery tip, without the user typing. Requires a key + data consent.
+ /// K1: streams the brief the same way `send` does.
func startBriefIfNeeded() async {
guard isConfigured, dataConsent, messages.isEmpty, !sending else { return }
guard let key = resolvedKey else { return }
errorText = nil
sending = true
- defer { sending = false }
+ defer { sending = false; persistMessages() }
let context = await buildFullContext()
- let instruction = """
- Based on the data above, give me TODAY'S coaching brief in three short parts: \
- (1) my readiness in one line, citing charge, HRV and rest; \
- (2) exactly what training to do today and what to avoid; \
- (3) one specific thing to improve my charge. Be punchy and motivating.
- """
- let wire: [(role: ChatMessage.Role, content: String)] = [(.user, context + "\n\n---\n\n" + instruction)]
+ let wire: [(role: ChatMessage.Role, content: String)] =
+ [(.user, context + "\n\n---\n\n" + Self.briefInstruction)]
+
+ let prefix = "Today's brief\n\n"
+ let placeholder = ChatMessage(role: .assistant, text: prefix)
+ appendMessage(placeholder)
+ var accumulated = ""
+
do {
- let reply = try await callProvider(key: key, messages: wire)
- let clean = reply.trimmingCharacters(in: .whitespacesAndNewlines)
- if !clean.isEmpty {
- appendMessage(ChatMessage(role: .assistant, text: "Today's brief\n\n" + clean))
+ try await streamProvider(key: key, messages: wire) { delta in
+ accumulated += delta
+ if let lastIdx = self.messages.indices.last,
+ self.messages[lastIdx].role == .assistant {
+ self.messages[lastIdx] = ChatMessage(
+ id: placeholder.id, role: .assistant, text: prefix + accumulated
+ )
+ }
+ }
+ let clean = accumulated.trimmingCharacters(in: .whitespacesAndNewlines)
+ if clean.isEmpty {
+ if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages.remove(at: lastIdx)
+ }
+ } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages[lastIdx] = ChatMessage(id: placeholder.id, role: .assistant, text: prefix + clean)
}
} catch let e as AICoachError {
+ let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines)
+ if partial.isEmpty {
+ if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages.remove(at: lastIdx)
+ }
+ } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages[lastIdx] = ChatMessage(
+ id: placeholder.id, role: .assistant,
+ text: prefix + partial + "\n\n*(stream interrupted)*"
+ )
+ }
errorText = e.errorDescription
} catch {
+ let partial = accumulated.trimmingCharacters(in: .whitespacesAndNewlines)
+ if partial.isEmpty {
+ if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages.remove(at: lastIdx)
+ }
+ } else if let lastIdx = messages.indices.last, messages[lastIdx].role == .assistant {
+ messages[lastIdx] = ChatMessage(
+ id: placeholder.id, role: .assistant,
+ text: prefix + partial + "\n\n*(stream interrupted)*"
+ )
+ }
errorText = AICoachError.network(error.localizedDescription).errorDescription
}
}
+ /// K5: The brief instruction shared by the interactive `startBriefIfNeeded()` (streamed into the
+ /// chat) and the headless `generateBrief()` below (used by the scheduled morning-brief notification).
+ /// Kept in one place so the two paths never drift.
+ private static let briefInstruction = """
+ Based on the data above, give me TODAY'S coaching brief in three short parts: \
+ (1) my readiness in one line, citing charge, HRV and rest; \
+ (2) exactly what training to do today and what to avoid; \
+ (3) one specific thing to improve my charge. Be punchy and motivating.
+ """
+
+ /// K5: Generate today's coaching brief WITHOUT touching the visible chat transcript. Used by the
+ /// scheduled morning-brief notification (`CoachBriefScheduler`), which can run with no Coach screen
+ /// open and must never append to (or duplicate into) `messages`. Non-streaming (a background/BGTask
+ /// context has no UI to stream into). Returns nil when not configured/consented, on any network
+ /// failure, or when the reply is empty — the caller treats nil as "brief unavailable"; never throws.
+ func generateBrief() async -> String? {
+ guard isConfigured, dataConsent, let key = resolvedKey else { return nil }
+ let context = await buildFullContext()
+ let wire: [(role: ChatMessage.Role, content: String)] =
+ [(.user, context + "\n\n---\n\n" + Self.briefInstruction)]
+ guard let reply = try? await callProvider(key: key, messages: wire) else { return nil }
+ let clean = reply.trimmingCharacters(in: .whitespacesAndNewlines)
+ return clean.isEmpty ? nil : clean
+ }
+
/// Full data context = the metrics summary + recent workouts (+ an OPT-IN on-device-signals summary
/// when the second consent is on). Used when the user has granted data access.
func buildFullContext() async -> String {
@@ -553,7 +822,7 @@ final class AICoachEngine: ObservableObject {
}
/// One derived stress line for the coach context: the Baevsky Stress Index over TODAY's R-R, read
- /// via the store exactly as `StressView` does (`storeHandle()` → `rrIntervals(deviceId:from:to:)`),
+ /// via the same device-aware repository R-R union as `StressView`,
/// then summarised to a single number with `StressIndex.stressIndex(rr:)`. Returns nil when the
/// store is unavailable or there are too few clean beats (the histogram needs >= 20), so the line is
/// simply absent, never a fabricated value. Summary-only: the raw R-R never leaves the device.
@@ -561,9 +830,7 @@ final class AICoachEngine: ObservableObject {
let cal = Calendar.current
let from = Int(cal.startOfDay(for: Date()).timeIntervalSince1970)
let to = Int(Date().timeIntervalSince1970)
- guard let store = await repo.storeHandle() else { return nil }
- let rr = (try? await store.rrIntervals(
- deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? []
+ let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000)
guard let si = StressIndex.stressIndex(rr: rr) else { return nil }
return Self.stressIndexSummary(si: si)
}
@@ -583,13 +850,20 @@ final class AICoachEngine: ObservableObject {
// 1. Strongest behaviour→outcome associations (EffectRanker over the journal × Charge).
let entries = await repo.journalEntries()
+ // Yes days and NO days, kept apart. A day with no journal row for the question lands in
+ // neither, so an unanswered day is never counted as a No (BehaviorInsights.effect).
var byBehaviour: [String: Set] = [:]
- for e in entries where e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) }
+ var controls: [String: Set] = [:]
+ for e in entries {
+ if e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) }
+ else { controls[e.question, default: []].insert(e.day) }
+ }
if !byBehaviour.isEmpty {
let outcomeByDay = Dictionary(
repo.days.compactMap { d in d.recovery.map { (d.day, $0) } },
uniquingKeysWith: { _, last in last })
- let ranked = EffectRanker.rank(behaviors: byBehaviour, outcomeByDay: outcomeByDay, outcome: "Charge")
+ let ranked = EffectRanker.rank(behaviors: byBehaviour, controls: controls,
+ outcomeByDay: outcomeByDay, outcome: "Charge")
.filter { $0.effect.significant }
.prefix(3)
if !ranked.isEmpty {
@@ -633,18 +907,118 @@ final class AICoachEngine: ObservableObject {
)
}
+ /// K1: Dispatch to the user's chosen provider client's streaming method. The default
+ /// `AIProviderClient.stream` falls back to `send` + a single delta, so providers without
+ /// streaming still work. K11: when an inline image is present, dispatches to
+ /// `streamWithImage` instead (Gemini overrides it; others ignore the image).
+ private func streamProvider(key: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ inlineImage: String? = nil,
+ onDelta: (String) -> Void) async throws {
+ try await provider.client.streamWithImage(
+ key: key,
+ model: model,
+ systemPrompt: systemPrompt,
+ messages: messages,
+ inlineImage: inlineImage,
+ session: session,
+ onDelta: onDelta
+ )
+ }
+
/// Sliding window over the chat: the FIRST user turn (it carries the metrics context) plus the most
/// recent `maxHistoryMessages`, dropping the middle. Sending the whole growing history crowds out the
/// reply on small-context local servers (Ollama defaults to a 2048-token window, the Custom
/// provider's main use case) and balloons token cost/latency on cloud providers. (parity with Android)
+ /// True when a transcript last written on `lastEpochDay` should be retired before a question asked
+ /// on `todayEpochDay` — i.e. the conversation crossed into a new local day.
+ ///
+ /// STRICTLY forward (`>`, never `!=`): a clock that moves BACKWARDS — the user flying west, a
+ /// timezone change, an NTP correction — must not wipe a conversation they are in the middle of.
+ /// Only real elapsed days retire a transcript. A nil `lastEpochDay` (nothing sent yet) is never
+ /// stale. Kotlin twin: `CoachViewModel.isStaleConversation`.
+ ///
+ /// `nonisolated` because it is a pure function of its arguments. AICoachEngine is @MainActor, so
+ /// without this the rule inherits that isolation and cannot be called from a synchronous test —
+ /// which is exactly how the first attempt at this twin failed to compile. Isolating a function
+ /// that touches no state buys nothing and costs its testability.
+ nonisolated static func isStaleConversation(lastEpochDay: Int?, todayEpochDay: Int) -> Bool {
+ guard let lastEpochDay else { return false }
+ return todayEpochDay > lastEpochDay
+ }
+
+ /// Days since the epoch in the LOCAL calendar — the twin of Kotlin's `LocalDate.now().toEpochDay()`.
+ ///
+ /// Counted with calendar day arithmetic from `startOfDay`, not by dividing an interval by 86,400:
+ /// a day is not always 86,400 seconds (DST), and the rule only needs a value that increments
+ /// exactly once per local midnight and orders correctly. Injectable so the tests never depend on
+ /// the machine's clock or zone.
+ nonisolated static func localEpochDay(_ date: Date = Date(), calendar: Calendar = .current) -> Int {
+ let start = calendar.startOfDay(for: date)
+ let epoch = Date(timeIntervalSince1970: 0)
+ return calendar.dateComponents([.day], from: epoch, to: start).day ?? 0
+ }
+
+ ///
+ /// K13: when the middle is dropped, a one-line summary of the dropped turns is prepended to the
+ /// first user turn so the model retains context continuity (instead of seeing a gap). The summary
+ /// is generated via the same provider, with a short prompt; on failure it degrades to the old
+ /// behaviour (no summary, just the windowed set).
private static let maxHistoryMessages = 10
+ /// K13: the cached summary of the dropped middle, regenerated when the dropped set changes.
+ private var droppedSummary: String?
+ private var droppedSummaryKey: [String] = []
+
private func windowedMessages() -> [ChatMessage] {
guard messages.count > Self.maxHistoryMessages + 1,
let firstUser = messages.firstIndex(where: { $0.role == .user }) else { return messages }
let recentStart = messages.count - Self.maxHistoryMessages
// If the first user turn already falls inside the recent window, that window covers it.
if firstUser >= recentStart { return Array(messages.suffix(Self.maxHistoryMessages)) }
- return [messages[firstUser]] + Array(messages[recentStart...])
+ // K13: inject the summary of the dropped middle by prepending it to the first user turn,
+ // so the model sees continuity instead of a gap. We don't use a separate system message
+ // because the Role enum only has .user/.assistant (providers map those to API roles).
+ var windowed = [messages[firstUser]]
+ if let summary = droppedSummary {
+ let first = windowed[0]
+ windowed[0] = ChatMessage(id: first.id, role: first.role, text: "\(summary)\n\n---\n\n\(first.text)")
+ }
+ windowed.append(contentsOf: messages[recentStart...])
+ return windowed
+ }
+
+ /// K13: When the conversation overflows the sliding window, summarize the dropped middle turns
+ /// into a single system message. Called before each send when the window would drop messages.
+ /// Best-effort: on any failure, leaves `droppedSummary` nil (the old gap behaviour).
+ private func summarizeDroppedMiddleIfNeeded(key: String) async {
+ guard messages.count > Self.maxHistoryMessages + 1,
+ let firstUser = messages.firstIndex(where: { $0.role == .user }) else { return }
+ let recentStart = messages.count - Self.maxHistoryMessages
+ guard firstUser < recentStart else { return }
+
+ // The dropped middle is messages[firstUser+1 ..< recentStart]. Cache on its identity so we
+ // don't re-summarize the same set on every send.
+ let dropped = Array(messages[(firstUser + 1).. String {
let rows = await repo.workoutRows(days: 30) // newest first
guard !rows.isEmpty else { return "Recent workouts: none recorded in the last 30 days." }
+ let bodySystem = UnitSystem(
+ rawValue: UserDefaults.standard.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric
+ let distanceSystem = UnitPrefs.resolveDistance(
+ system: bodySystem,
+ override: UserDefaults.standard.string(forKey: UnitPrefs.distanceSystemKey) ?? "")
var lines = ["Recent workouts (newest first):"]
for w in rows.prefix(limit) {
var parts = [" \(dateString(w.startTs)) \(w.sport)"]
@@ -719,7 +1099,9 @@ final class AICoachEngine: ObservableObject {
if let s = w.strain { parts.append("effort \(String(format: "%.1f", s))") }
if let hr = w.avgHr { parts.append("avg HR \(hr)") }
if let kcal = w.energyKcal { parts.append("\(Int(kcal.rounded())) kcal") }
- if let dist = w.distanceM { parts.append("\(String(format: "%.1f", dist / 1000)) km") }
+ if let dist = w.distanceM {
+ parts.append(UnitFormatter.distanceFromMeters(dist, system: distanceSystem))
+ }
lines.append(parts.joined(separator: ", "))
}
return lines.joined(separator: "\n")
@@ -727,16 +1109,57 @@ final class AICoachEngine: ObservableObject {
// MARK: Formatting helpers
- private func dayLine(_ d: DailyMetric) -> String {
+ /// `internal`, not private, so `AICoachSleepContextTests` can assert the emitted line directly.
+ /// Swift's `buildContext()` takes no arguments (it reads the repo), unlike the Kotlin twin which is
+ /// handed the day list — so without this the formatter has no seam and the Swift half of a change
+ /// with fifteen Kotlin tests would ship untested.
+ func dayLine(_ d: DailyMetric) -> String {
var parts: [String] = [d.day + ":"]
parts.append("charge " + (d.recovery.map { "\(Int($0.rounded()))" } ?? "—"))
parts.append("effort " + (d.strain.map { String(format: "%.1f", $0) } ?? "—"))
parts.append("rest " + (d.totalSleepMin.map { String(format: "%.1fh", $0 / 60) } ?? "—"))
+ // The stage breakdown and efficiency, which the coach could not see at all: a user asked why it
+ // said it had no access to sleep stages, and it was answering honestly — `rest 7.8h` was every
+ // word it got about a night. These four sit on the SAME DailyMetric the line already reads, so
+ // nothing new is plumbed; they were simply never included. (#124 widened this context once
+ // before, for the same reason.)
+ //
+ // Always emitted, "—" when absent, like every other field here. A night with no staging then
+ // says so rather than going quiet, which matters more than line length: the alternative — only
+ // appending stages when present — gives the model a schema that changes shape between days and
+ // invites it to read a missing field as a zero.
+ parts.append("deep " + hoursOrDash(d.deepMin))
+ parts.append("REM " + hoursOrDash(d.remMin))
+ parts.append("light " + hoursOrDash(d.lightMin))
+ parts.append("eff " + efficiencyPercentOrDash(d.efficiency))
parts.append("HRV " + (d.avgHrv.map { "\(Int($0.rounded()))ms" } ?? "—"))
parts.append("RHR " + (d.restingHr.map { "\($0)bpm" } ?? "—"))
return parts.joined(separator: ", ")
}
+ /// Minutes as "1.4h", or "—" when the night has no value. Matches the `rest` field's format so a
+ /// stage total and the total it is part of read on the same scale.
+ private func hoursOrDash(_ minutes: Double?) -> String {
+ minutes.map { String(format: "%.1fh", $0 / 60) } ?? "—"
+ }
+
+ /// Efficiency as a percentage, NORMALISING the stored value first.
+ ///
+ /// `DailyMetric.efficiency` is not reliably a 0–1 fraction: it "arrives as % on some import paths",
+ /// which `SleepView` and `StagesCard` each guard against inline with this same `> 1.5` test. A bare
+ /// `* 100` would therefore hand the coach "eff 9400%" for an imported night — and a model given a
+ /// nonsense number reasons about it confidently rather than ignoring it.
+ ///
+ /// 1.5 rather than 1.0 because a genuine fraction can exceed 1.0 only by floating-point noise, while
+ /// a genuine percentage is 30–100 and nowhere near the threshold. Android's two copies of this guard
+ /// split at 1.0 instead, which is a pre-existing divergence and not this change's to settle.
+ func efficiencyPercentOrDash(_ raw: Double?) -> String {
+ guard var e = raw, e > 0 else { return "—" }
+ if e > 1.5 { e /= 100 }
+ guard e > 0, e <= 1 else { return "—" }
+ return "\(Int((e * 100).rounded()))%"
+ }
+
private func avgOne(_ xs: [Double]) -> String {
guard !xs.isEmpty else { return "—" }
return String(format: "%.1f", xs.reduce(0, +) / Double(xs.count))
diff --git a/Strand/AI/AIProvider.swift b/Strand/AI/AIProvider.swift
index 1aeeffa652..c2306ffe58 100644
--- a/Strand/AI/AIProvider.swift
+++ b/Strand/AI/AIProvider.swift
@@ -1,4 +1,5 @@
import Foundation
+import StrandAnalytics
// MARK: - Provider enum
@@ -227,6 +228,65 @@ protocol AIProviderClient {
/// Fetch the provider's live model list and return plain model ids.
func fetchModels(key: String, session: URLSession) async throws -> [String]
+
+ /// Stream a chat turn, calling `onDelta` for each text chunk as it arrives. The concatenated
+ /// deltas must equal the text that `send` would return for the same inputs (byte-parity with
+ /// the non-streamed path). The default implementation falls back to `send` + a single delta,
+ /// so providers without streaming still work. K1.
+ func stream(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws
+
+ /// K11: Stream a chat turn with an optional inline image (base64 PNG). Only Gemini implements
+ /// this; the default implementation ignores the image and calls `stream`. This keeps the
+ /// multimodal path opt-in without changing every provider's `stream` signature.
+ func streamWithImage(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ inlineImage: String?,
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws
+}
+
+extension AIProviderClient {
+ /// K11: Default — ignore the image, delegate to `stream`. Providers without multimodal support
+ /// (OpenAI, Anthropic, Custom) use this; only Gemini overrides it.
+ func streamWithImage(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ inlineImage: String?,
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws {
+ try await stream(key: key, model: model, systemPrompt: systemPrompt,
+ messages: messages, session: session, onDelta: onDelta)
+ }
+}
+
+extension AIProviderClient {
+ /// Default: fall back to the non-streaming `send` and emit the full reply as one delta.
+ func stream(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws {
+ let reply = try await send(key: key, model: model, systemPrompt: systemPrompt,
+ messages: messages, session: session)
+ onDelta(reply)
+ }
}
// MARK: - Shared HTTP helpers
@@ -283,3 +343,45 @@ func emptyReplyError(_ json: [String: Any]) -> AICoachError {
return .emptyReply("The provider returned an empty reply. If you set a custom model by hand, check "
+ "that the model name is one the provider actually offers.")
}
+
+// MARK: - SSE streaming helper (K1)
+
+/// Execute a streaming SSE request, map HTTP status codes to `AICoachError`, and call `onLine`
+/// for each `data:` payload line (prefix stripped, blank/comment/non-data lines filtered). The
+/// caller's `onLine` closure uses the pure `SseDeltas` functions to extract the per-provider text
+/// delta. Throws on HTTP errors (same mapping as `performRequest`). K1.
+func performStreamingRequest(
+ _ req: URLRequest,
+ session: URLSession,
+ onLine: (String) -> Void
+) async throws {
+ let bytes: (URLSession.AsyncBytes, URLResponse)
+ do {
+ bytes = try await session.bytes(for: req)
+ } catch {
+ throw AICoachError.network(error.localizedDescription)
+ }
+
+ guard let http = bytes.1 as? HTTPURLResponse else {
+ throw AICoachError.network("no HTTP response")
+ }
+
+ switch http.statusCode {
+ case 200...299:
+ // Read line-by-line from the SSE byte stream. `URLSession.AsyncBytes` splits on \n.
+ for try await line in bytes.0.lines {
+ if let payload = SseDeltas.dataPayload(fromLine: line) {
+ onLine(payload)
+ }
+ }
+ case 401, 403:
+ throw AICoachError.badKey
+ case 429:
+ throw AICoachError.rateLimited
+ default:
+ // For non-200, the body is a (non-streaming) error JSON — collect it and surface the message.
+ var body = ""
+ for try await line in bytes.0.lines { body += line }
+ throw AICoachError.server(http.statusCode, providerErrorMessage(from: Data(body.utf8)))
+ }
+}
diff --git a/Strand/AI/Providers/Anthropic.swift b/Strand/AI/Providers/Anthropic.swift
index 1e5071ef24..2b80518b32 100644
--- a/Strand/AI/Providers/Anthropic.swift
+++ b/Strand/AI/Providers/Anthropic.swift
@@ -1,4 +1,5 @@
import Foundation
+import StrandAnalytics
struct AnthropicClient: AIProviderClient {
@@ -39,6 +40,42 @@ struct AnthropicClient: AIProviderClient {
return text
}
+ /// K1: Stream via `stream: true`. Anthropic SSE uses typed events; we extract `content_block_delta`
+ /// with `text_delta` via `SseDeltas.anthropicDelta`. Byte-parity pin in
+ /// `SseDeltasTests.anthropicReassembleMatchesFullReply`.
+ func stream(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws {
+ var wire: [[String: Any]] = []
+ for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) }
+
+ let body: [String: Any] = [
+ "model": model,
+ "max_tokens": 4096,
+ "system": systemPrompt,
+ "messages": wire,
+ "stream": true
+ ]
+
+ var req = URLRequest(url: AIProvider.anthropic.endpoint)
+ req.httpMethod = "POST"
+ req.setValue(key, forHTTPHeaderField: "x-api-key")
+ req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
+ req.setValue("application/json", forHTTPHeaderField: "content-type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ try await performStreamingRequest(req, session: session) { payload in
+ if let delta = SseDeltas.anthropicDelta(payload) {
+ onDelta(delta)
+ }
+ }
+ }
+
func fetchModels(key: String, session: URLSession) async throws -> [String] {
var req = URLRequest(url: AIProvider.anthropic.modelsEndpoint)
req.httpMethod = "GET"
diff --git a/Strand/AI/Providers/Custom.swift b/Strand/AI/Providers/Custom.swift
index 5c1a8d8627..a52b33c229 100644
--- a/Strand/AI/Providers/Custom.swift
+++ b/Strand/AI/Providers/Custom.swift
@@ -1,4 +1,5 @@
import Foundation
+import StrandAnalytics
/// A generic OpenAI-compatible provider pointed at a user-set base URL — e.g. a local LLM server such
/// as Ollama, LM Studio or llama.cpp (`http://localhost:11434/v1`), or any self-hosted gateway. Speaks
@@ -31,6 +32,42 @@ struct CustomClient: AIProviderClient {
}
}
+ /// K1: Stream via `stream: true` (most local OpenAI-compatible servers support it). Same body
+ /// as `send`'s standard-params path, with `stream: true`. SSE parsing via `SseDeltas.openAiDelta`.
+ /// The modern-params retry on 400 is NOT streamed (rare path; falls back to `send`'s retry).
+ func stream(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws {
+ try AIProvider.guardCustomBaseURL() // #321: reject a public cleartext Custom URL before egress
+ var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]]
+ for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) }
+
+ let body: [String: Any] = [
+ "model": model,
+ "messages": wire,
+ "temperature": 0.6,
+ "max_tokens": 4096,
+ "stream": true
+ ]
+
+ var req = URLRequest(url: AIProvider.custom.endpoint)
+ req.httpMethod = "POST"
+ AIProvider.applyCustomAuthHeader(key, to: &req)
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ try await performStreamingRequest(req, session: session) { payload in
+ if let delta = SseDeltas.openAiDelta(payload) {
+ onDelta(delta)
+ }
+ }
+ }
+
/// Appended to a reply when the server stopped early because it ran out of context window.
/// Local OpenAI-compatible servers (notably Ollama, which defaults to a 2048-token window and
/// IGNORES `num_ctx` on the `/v1` endpoint) truncate silently — no error, the text just stops
diff --git a/Strand/AI/Providers/Gemini.swift b/Strand/AI/Providers/Gemini.swift
index 3c7f9a54bb..ccf4829b22 100644
--- a/Strand/AI/Providers/Gemini.swift
+++ b/Strand/AI/Providers/Gemini.swift
@@ -1,4 +1,5 @@
import Foundation
+import StrandAnalytics
struct GeminiClient: AIProviderClient {
@@ -9,14 +10,7 @@ struct GeminiClient: AIProviderClient {
messages: [(role: ChatMessage.Role, content: String)],
session: URLSession
) async throws -> String {
- var contents: [[String: Any]] = []
- for m in messages {
- contents.append([
- "role": m.role == .assistant ? "model" : "user",
- "parts": [["text": m.content]]
- ])
- }
-
+ let contents = GeminiClient.buildContents(from: messages)
let body: [String: Any] = [
"system_instruction": ["parts": [["text": systemPrompt]]],
"contents": contents,
@@ -52,8 +46,104 @@ struct GeminiClient: AIProviderClient {
return text
}
- func fetchModels(key: String, session: URLSession) async throws -> [String] {
- var req = URLRequest(url: AIProvider.gemini.modelsEndpoint)
+ /// K11: Build Gemini "contents" array from wire messages. Extracted so the multimodal path
+ /// (which appends an `inline_data` image part to the last user turn) can reuse it.
+ static func buildContents(from messages: [(role: ChatMessage.Role, content: String)]) -> [[String: Any]] {
+ var contents: [[String: Any]] = []
+ for m in messages {
+ contents.append([
+ "role": m.role == .assistant ? "model" : "user",
+ "parts": [["text": m.content]]
+ ])
+ }
+ return contents
+ }
+
+ /// K11: Append a base64-encoded image to the last user turn's parts (Gemini multimodal).
+ /// The image is sent as an `inline_data` part alongside the existing text part.
+ static func appendImage(_ base64: String, mimeType: String, to contents: inout [[String: Any]]) {
+ guard let lastUserIdx = contents.lastIndex(where: { ($0["role"] as? String) == "user" }) else { return }
+ var parts = contents[lastUserIdx]["parts"] as? [[String: Any]] ?? []
+ parts.append(["inline_data": ["mime_type": mimeType, "data": base64]])
+ contents[lastUserIdx]["parts"] = parts
+ }
+
+ /// K1: Stream via `:streamGenerateContent?alt=sse`. Same body as `send`, different endpoint +
+ /// SSE parsing. The concatenated deltas are byte-identical to `send`'s return (parity pin in
+ /// `SseDeltasTests.geminiReassembleMatchesFullReply`).
+ func stream(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws {
+ let contents = GeminiClient.buildContents(from: messages)
+
+ let body: [String: Any] = [
+ "system_instruction": ["parts": [["text": systemPrompt]]],
+ "contents": contents,
+ "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096]
+ ]
+
+ // :streamGenerateContent?alt=sse — SSE line-per-chunk. Built via URL(string:) to avoid
+ // appendingPathComponent percent-encoding the ":" (same note as `send`).
+ guard let url = URL(string: "\(AIProvider.gemini.endpoint.absoluteString)/\(model):streamGenerateContent?alt=sse") else {
+ throw AICoachError.network("invalid model id")
+ }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue(key, forHTTPHeaderField: "x-goog-api-key")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ try await performStreamingRequest(req, session: session) { payload in
+ if let delta = SseDeltas.geminiDelta(payload) {
+ onDelta(delta)
+ }
+ }
+ }
+
+ /// K11: Stream with an optional inline image (base64 PNG). Appends the image as
+ /// `inline_data` to the last user turn's parts, then streams as normal.
+ func streamWithImage(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ inlineImage: String?,
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws {
+ var contents = GeminiClient.buildContents(from: messages)
+ if let image = inlineImage {
+ GeminiClient.appendImage(image, mimeType: "image/png", to: &contents)
+ }
+
+ let body: [String: Any] = [
+ "system_instruction": ["parts": [["text": systemPrompt]]],
+ "contents": contents,
+ "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096]
+ ]
+
+ guard let url = URL(string: "\(AIProvider.gemini.endpoint.absoluteString)/\(model):streamGenerateContent?alt=sse") else {
+ throw AICoachError.network("invalid model id")
+ }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue(key, forHTTPHeaderField: "x-goog-api-key")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ try await performStreamingRequest(req, session: session) { payload in
+ if let delta = SseDeltas.geminiDelta(payload) {
+ onDelta(delta)
+ }
+ }
+ }
+
+ func fetchModels(key: String, session: URLSession) async throws -> [String] { var req = URLRequest(url: AIProvider.gemini.modelsEndpoint)
req.httpMethod = "GET"
req.setValue(key, forHTTPHeaderField: "x-goog-api-key")
diff --git a/Strand/AI/Providers/OpenAI.swift b/Strand/AI/Providers/OpenAI.swift
index 30f9a295e7..78f06f5658 100644
--- a/Strand/AI/Providers/OpenAI.swift
+++ b/Strand/AI/Providers/OpenAI.swift
@@ -1,4 +1,5 @@
import Foundation
+import StrandAnalytics
struct OpenAIClient: AIProviderClient {
@@ -26,6 +27,37 @@ struct OpenAIClient: AIProviderClient {
}
}
+ /// K1: Stream via `stream: true`. Same body as `send`, with `stream: true` added. SSE parsing
+ /// via `SseDeltas.openAiDelta`. The modern-params retry on 400 is NOT streamed (rare path;
+ /// falls back to `send`'s retry). Byte-parity pin in `SseDeltasTests.openAiReassembleMatchesFullReply`.
+ func stream(
+ key: String,
+ model: String,
+ systemPrompt: String,
+ messages: [(role: ChatMessage.Role, content: String)],
+ session: URLSession,
+ onDelta: (String) -> Void
+ ) async throws {
+ var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]]
+ for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) }
+
+ var body: [String: Any] = ["model": model, "messages": wire, "stream": true]
+ body["temperature"] = 0.6
+ body["max_tokens"] = 4096
+
+ var req = URLRequest(url: AIProvider.openAI.endpoint)
+ req.httpMethod = "POST"
+ req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ try await performStreamingRequest(req, session: session) { payload in
+ if let delta = SseDeltas.openAiDelta(payload) {
+ onDelta(delta)
+ }
+ }
+ }
+
func fetchModels(key: String, session: URLSession) async throws -> [String] {
var req = URLRequest(url: AIProvider.openAI.modelsEndpoint)
req.httpMethod = "GET"
diff --git a/Strand/App/ActiveWorkoutClock.swift b/Strand/App/ActiveWorkoutClock.swift
new file mode 100644
index 0000000000..b6798cf730
--- /dev/null
+++ b/Strand/App/ActiveWorkoutClock.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+/// The ONE pause-aware elapsed calculation for an in-flight workout, shared by every surface that shows
+/// its clock.
+///
+/// #1533 added Pause/Resume to the full-screen live workout view and taught THAT view's timer to subtract
+/// the paused time. The Today "workout in progress" indicator and the Live tab's active-workout card were
+/// not part of it: both still read `now - start`. So pausing froze one clock and left two others counting
+/// up, and a wearer who paused and backed out saw the session apparently still running. The saved duration
+/// was correct the whole time, which is what made it confusing rather than merely wrong — the only
+/// evidence on screen contradicted the button that had just been pressed.
+///
+/// Kept pure and in one place so a new surface cannot reintroduce the divergence by open-coding the
+/// subtraction again. Twin of Kotlin `ActiveWorkoutClock`.
+enum ActiveWorkoutClock {
+
+ /// Seconds of ACTIVE time: wall time since `start`, minus every completed pause, minus the one still
+ /// open if the session is paused right now.
+ ///
+ /// Clamped at zero so a clock-skew negative reads 0:00 rather than counting backwards. The open-pause
+ /// term is deliberately NOT clamped on its own: the single clamp on the result is what the Kotlin twin
+ /// does, and two clamps would disagree with it for a `pausedAt` in the future.
+ ///
+ /// Twin of Kotlin `ActiveWorkoutClock.activeElapsedSeconds` — the NAMES differ (this one takes
+ /// `Date`/`TimeInterval`, that one Long milliseconds), so neither turns up in a grep for the other.
+ /// Same arithmetic and the same single clamp; the platforms truncate to whole seconds at different
+ /// points and agree because both truncate toward zero.
+ static func activeElapsed(start: Date, pausedAt: Date?, pausedDuration: TimeInterval,
+ now: Date = Date()) -> TimeInterval {
+ max(0, now.timeIntervalSince(start) - pausedDuration
+ - (pausedAt.map { now.timeIntervalSince($0) } ?? 0))
+ }
+
+ /// `M:SS` up to an hour, `H:MM:SS` once an hour has passed, so a 90-minute session reads "1:30:00"
+ /// rather than "90:00". Negative input clamps to "0:00".
+ ///
+ /// The Live card used to carry its own `%d:%02d` formatter with no hour roll-over at all, so a long
+ /// session read "90:00" there while the Today indicator beside it read "1:30:00" — and Android's
+ /// shared `elapsedClock` documents itself as mirroring the iOS one, which by then it no longer did.
+ /// Twin of Kotlin `elapsedClock`.
+ static func clock(_ seconds: Int) -> String {
+ let total = max(0, seconds)
+ let h = total / 3600
+ let m = (total % 3600) / 60
+ let s = total % 60
+ return h > 0
+ ? String(format: "%d:%02d:%02d", h, m, s)
+ : String(format: "%d:%02d", m, s)
+ }
+}
diff --git a/Strand/App/AppClock.swift b/Strand/App/AppClock.swift
new file mode 100644
index 0000000000..371ae251c7
--- /dev/null
+++ b/Strand/App/AppClock.swift
@@ -0,0 +1,122 @@
+import Foundation
+import StrandAnalytics
+
+/// #1821: the app-side reader for the Clock format setting. Sits next to `AppLanguage` because it
+/// answers the same shape of question - what conventions do we render in - and because the bug it fixes
+/// lives in `AppLanguage.activeLocale`.
+///
+/// `activeLocale` builds `language_REGION` on purpose, to keep the device's regional date order and
+/// clock style. But a locale built from a REGION identifier carries that region's DEFAULT hour cycle,
+/// and the user's explicit Settings > General > Date & Time > 24-Hour Time switch is not part of it -
+/// that override lives on `Locale.autoupdatingCurrent`. So the switch was silently discarded, and a
+/// reader in a 24-hour region could not get a 12-hour clock by any means at all.
+enum AppClock {
+ /// The stored preference, defaulting to `.system` so an upgrade changes nobody's display.
+ static var preference: ClockFormatPreference {
+ ClockFormatPreference.from(stored: UserDefaults.standard.string(forKey: ClockFormatPreference.defaultsKey))
+ }
+
+ /// What the DEVICE is set to, read from `autoupdatingCurrent` precisely because that is the locale
+ /// the 24-Hour Time switch modifies.
+ ///
+ /// #1829: MEMOISED. This calls `DateFormatter.dateFormat(fromTemplate:options:locale:)`, which builds
+ /// an ICU pattern generator - one of the more expensive things Foundation offers - and the first cut
+ /// of this ran it on EVERY access, ahead of the formatter cache. Since ~17 call sites had just been
+ /// converted from `static let` to computed properties, that put an ICU build behind every time label
+ /// on screen. The cache saved the cheap allocation and missed the expensive call entirely.
+ static var systemUses24Hour: Bool {
+ // Arm the invalidator HERE, not in hourMinuteFormatter(): four call sites reach AppClock only
+ // through `formattingLocale` and would never have registered it, leaving their memo pinned to a
+ // stale clock for the life of the process. Every path lands here — Swift evaluates both arguments
+ // of ClockFormat.uses24Hour(preference:systemUses24Hour:) eagerly — so this is the one chokepoint.
+ // Touched before the lock: registration does not need it, and taking it twice would not nest.
+ _ = localeObserver
+ cacheLock.lock(); defer { cacheLock.unlock() }
+ if let cachedSystem24 { return cachedSystem24 }
+ let template = DateFormatter.dateFormat(fromTemplate: "j", options: 0,
+ locale: Locale.autoupdatingCurrent) ?? "H"
+ let v = !template.contains("a")
+ cachedSystem24 = v
+ return v
+ }
+
+ static var uses24Hour: Bool {
+ ClockFormat.uses24Hour(preference: preference, systemUses24Hour: systemUses24Hour)
+ }
+
+ /// #1829: memoised for the same reason - `AppLanguage.activeLocale` reads
+ /// `Bundle.main.preferredLocalizations` and then CONSTRUCTS a `Locale`, per access.
+ static var activeLocale: Locale {
+ cacheLock.lock(); defer { cacheLock.unlock() }
+ if let cachedActiveLocale { return cachedActiveLocale }
+ let l = AppLanguage.activeLocale
+ cachedActiveLocale = l
+ return l
+ }
+
+ /// The locale to hand any time-rendering `DateFormatter`. Carries an explicit hour cycle, so the
+ /// setting also reaches formatters that never name a pattern (`timeStyle`, a `"jmm"` template).
+ static var formattingLocale: Locale {
+ let id = ClockFormat.hourCycleLocaleIdentifier(base: activeLocale.identifier,
+ uses24Hour: uses24Hour)
+ cacheLock.lock(); defer { cacheLock.unlock() }
+ if let cachedFormattingLocale, cachedFormattingLocaleID == id { return cachedFormattingLocale }
+ let l = Locale(identifier: id)
+ cachedFormattingLocale = l
+ cachedFormattingLocaleID = id
+ return l
+ }
+
+ private static var cached: (template: String, locale: String, formatter: DateFormatter)?
+ private static var cachedSystem24: Bool?
+ private static var cachedActiveLocale: Locale?
+ private static var cachedFormattingLocale: Locale?
+ private static var cachedFormattingLocaleID: String?
+ /// One lock for every cache here. Each accessor takes it around its own read-modify-write and
+ /// releases before returning, so none of them nest - NSLock is not recursive and a nested take would
+ /// deadlock the main thread, which is a far worse failure than the cost this exists to avoid.
+ private static let cacheLock = NSLock()
+
+ /// Drop every memo. Called when the reader changes the setting, and when the SYSTEM clock or locale
+ /// changes underneath us - `systemUses24Hour` can flip with no write of our own, so without this the
+ /// memo would pin a stale answer for the life of the process.
+ static func invalidate() {
+ cacheLock.lock()
+ cached = nil
+ cachedSystem24 = nil
+ cachedActiveLocale = nil
+ cachedFormattingLocale = nil
+ cachedFormattingLocaleID = nil
+ cacheLock.unlock()
+ }
+
+ /// #1829: registers exactly once, on first use — a `static let` is lazy and thread-safe in Swift, so
+ /// touching it below is the whole registration. The system 24-hour switch and the device locale can
+ /// both change with no write from us; without this the memo above would serve a stale answer for the
+ /// rest of the process, which is worse than the per-access cost it replaced.
+ private static let localeObserver: NSObjectProtocol = NotificationCenter.default.addObserver(
+ forName: NSLocale.currentLocaleDidChangeNotification, object: nil, queue: nil
+ ) { _ in AppClock.invalidate() }
+
+ /// Wall-clock time at minute precision, honouring the setting.
+ static func hourMinuteFormatter() -> DateFormatter {
+ let template = ClockFormat.hourMinuteTemplate(uses24Hour: uses24Hour)
+ let locale = activeLocale
+ cacheLock.lock(); defer { cacheLock.unlock() }
+ if let cached, cached.template == template, cached.locale == locale.identifier {
+ return cached.formatter
+ }
+ let f = DateFormatter()
+ f.locale = locale
+ f.setLocalizedDateFormatFromTemplate(template)
+ cached = (template, locale.identifier, f)
+ return f
+ }
+
+ /// Convenience for the many call sites that just want the string.
+ static func hourMinute(_ date: Date) -> String { hourMinuteFormatter().string(from: date) }
+
+ static func hourMinute(unix ts: Int) -> String {
+ hourMinute(Date(timeIntervalSince1970: TimeInterval(ts)))
+ }
+}
diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift
index 4de5e170f9..9e215541e4 100644
--- a/Strand/App/AppModel.swift
+++ b/Strand/App/AppModel.swift
@@ -118,9 +118,10 @@ final class AppModel: ObservableObject {
var isPaused: Bool { pausedAt != nil }
+ /// Delegates to `ActiveWorkoutClock` so this and the two card surfaces cannot drift apart again.
func elapsed(at now: Date = Date()) -> TimeInterval {
- max(0, now.timeIntervalSince(start) - pausedDuration
- - (pausedAt.map { now.timeIntervalSince($0) } ?? 0))
+ ActiveWorkoutClock.activeElapsed(start: start, pausedAt: pausedAt,
+ pausedDuration: pausedDuration, now: now)
}
}
struct HealthAlert: Equatable {
@@ -541,8 +542,12 @@ final class AppModel: ObservableObject {
registry: registry,
live: live,
storeHandle: { [weak self] in await self?.repo.storeHandle() },
- startWhoop: { [weak self] in self?.scan() },
- stopWhoop: { [weak self] in self?.disconnect() },
+ // #1881: the flag rides the SAME two closures, so it inherits the coordinator's semantics
+ // exactly — including the deliberate Apple Watch exception, which calls neither and so must
+ // never stop a live WHOOP. `stopWhoop` alone was edge-triggered: it dropped the link once and
+ // nothing stopped `poweredOn` / state restoration / the standing connect bringing it back.
+ startWhoop: { [weak self] in self?.ble.setWhoopIsActiveDevice(true); self?.scan() },
+ stopWhoop: { [weak self] in self?.ble.setWhoopIsActiveDevice(false); self?.disconnect() },
// WHOOP targeting hooks , thin wrappers over BLEManager's existing additive setters, so the
// coordinator never references BLEManager directly (mirrors the start/stop injection). On the
// single-WHOOP path these are setPreferredPeripheral(nil) and (no setActiveDeviceId call),
@@ -559,6 +564,12 @@ final class AppModel: ObservableObject {
})
coordinator.start()
self.deviceRegistry = registry
+ // #1303: adoption re-points the strap onto its stable `whoop-` id inside BLEManager (which
+ // holds only the non-observable store), so mirror it onto the OBSERVABLE registry here or the
+ // Devices screen and the source coordinator keep watching an id that no longer exists.
+ self.ble.onSerialIdentityAdopted = { [weak registry] serialId in
+ registry?.setActive(serialId)
+ }
self.sourceCoordinator = coordinator
// #814 READ SPINE (HIGH-1): drive the read side off the registry's `activeDeviceId` for the WHOLE
// session, exactly as SourceCoordinator drives the WRITE side off the SAME publisher. A Devices-
@@ -634,7 +645,7 @@ final class AppModel: ObservableObject {
// analyzeRecent tick , otherwise a just-synced night's Charge / Effort / Rest can take up to
// 15 minutes to appear on a strap-only (no-import) dashboard. analyzeRecent no-ops if a tick is
// already running and refreshes the dashboard itself once the new scores persist. (PR #218)
- // #1196/#1146: `skipIfUnchanged` gates THIS post-offload pass on the HR fingerprint — an empty/
+ // #1196/#1146: `skipIfUnchanged` gates THIS post-offload pass on the complete raw-input fingerprint — an empty/
// duplicate offload (nothing new banked, common on a flapping link) skips the whole-window rescore
// instead of churning it, which was surfacing as a Trends/streak "0 days" flicker. Only this
// post-offload caller opts in; every other analyzeRecent path still forces unconditionally.
@@ -1357,7 +1368,14 @@ final class AppModel: ObservableObject {
/// `log` (optional): strap-log sink for the not-authorized bail (#401 close-out) — a silent no-op left a
/// user whose backup never fired with nothing in the log. The caller wraps the sink in a main-actor hop
/// (the auth check completes off-main). Diagnostic only.
+ ///
+ /// #1864: `overrides` carries the per-weekday wake-time overrides (#554 / `WindDownNudge.perDayWakeOverrides`).
+ /// A weekday with an override fires at ITS OWN time, not the shared `minutes` — so a user who sets
+ /// "Tuesday 03:30" on the alarm screen is woken at 03:30 on Tuesday, not at the default time with only
+ /// the wind-down reminder shifting. An empty map (the default) is byte-for-byte the old path. Mirrors
+ /// Android's `SmartAlarmScheduler.arm` which reads `SmartAlarmStore.targetOverrides` per weekday.
static func scheduleSmartAlarmBackupNotification(minutes: Int, weekdays: Set,
+ overrides: [Int: Int] = [:],
log: ((String) -> Void)? = nil) {
#if os(iOS)
let center = UNUserNotificationCenter.current()
@@ -1372,6 +1390,9 @@ final class AppModel: ObservableObject {
let valid = weekdays.filter { (1...7).contains($0) }
// A non-empty selection that filters to nothing (only out-of-range numbers) has no day to fire on.
if !weekdays.isEmpty && valid.isEmpty { return }
+ // #1864: only valid override entries (day 1…7, minute in [0, 1440)) count; a day without an override
+ // uses the default `minutes`. When the map is empty this is byte-for-byte the old path.
+ let cleanOverrides = overrides.filter { (1...7).contains($0.key) && (0..<24 * 60).contains($0.value) }
// Build + add the repeating trigger(s). Factored so the already-authorized and the just-granted
// paths schedule identically.
@@ -1380,20 +1401,37 @@ final class AppModel: ObservableObject {
content.title = String(localized: "Smart alarm")
content.body = String(localized: "Backup wake: your smart alarm time is here.")
content.sound = .default
- let hour = minutes / 60
- let minute = minutes % 60
if weekdays.isEmpty {
- var comps = DateComponents()
- comps.hour = hour
- comps.minute = minute
- let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
- center.add(UNNotificationRequest(identifier: smartAlarmBackupId, content: content, trigger: trigger))
+ // Every day. When overrides exist, fan out to per-weekday triggers (each at its own time)
+ // so an override on a day the weekday set doesn't restrict still fires at the right time.
+ // Without overrides this stays the single daily trigger (byte-for-byte the old path).
+ if cleanOverrides.isEmpty {
+ let hour = minutes / 60
+ let minute = minutes % 60
+ var comps = DateComponents()
+ comps.hour = hour
+ comps.minute = minute
+ let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
+ center.add(UNNotificationRequest(identifier: smartAlarmBackupId, content: content, trigger: trigger))
+ } else {
+ for weekday in 1...7 {
+ let m = cleanOverrides[weekday] ?? minutes
+ var comps = DateComponents()
+ comps.weekday = weekday
+ comps.hour = m / 60
+ comps.minute = m % 60
+ let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
+ center.add(UNNotificationRequest(identifier: "\(smartAlarmBackupId)-d\(weekday)",
+ content: content, trigger: trigger))
+ }
+ }
} else {
for weekday in valid {
+ let m = cleanOverrides[weekday] ?? minutes
var comps = DateComponents()
comps.weekday = weekday // Calendar weekday 1=Sun…7=Sat , fires weekly on that day
- comps.hour = hour
- comps.minute = minute
+ comps.hour = m / 60
+ comps.minute = m % 60
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
center.add(UNNotificationRequest(identifier: "\(smartAlarmBackupId)-d\(weekday)",
content: content, trigger: trigger))
@@ -1434,14 +1472,25 @@ final class AppModel: ObservableObject {
/// On iOS this ALSO (dis)arms the best-effort backup wake notification (#4 + #6): a repeating daily
/// `UNCalendarNotificationTrigger` that survives suspend/relaunch, so a missed strap buzz still gets
/// an OS-level wake. macOS keeps just the firmware alarm (the static helpers are no-ops there).
+ ///
+ /// #1864: the per-weekday wake-time overrides (`WindDownNudge.perDayWakeOverrides`, #554) now flow
+ /// through to BOTH the strap firmware alarm and the backup notification — so a user who sets
+ /// "Tuesday 03:30" on the alarm screen is woken at 03:30 on Tuesday, not at the default time with
+ /// only the wind-down reminder shifting. Before this, the overrides had exactly two readers
+ /// (`wakeMinutes(forWeekday:)` → the nudge fan-out, and `SmartAlarmView` which edits them) and the
+ /// alarm backup took a single time plus a day set, so the control on the alarm screen silently moved
+ /// only the evening reminder. Mirrors Android's `reconcileStrapAlarm` which passes `dayOverrides`
+ /// to `nextSmartAlarmEpochSec`, and `SmartAlarmScheduler.arm` which reads `targetOverrides`.
func applySmartAlarm() {
+ let overrides = WindDownNudge.perDayWakeOverrides
guard behavior.smartAlarmEnabled else {
ble.disableStrapAlarm()
Self.cancelSmartAlarmBackupNotification()
return
}
guard let next = Self.nextSmartAlarmDate(minutes: behavior.smartAlarmMinutes,
- weekdays: behavior.smartAlarmWeekdays) else {
+ weekdays: behavior.smartAlarmWeekdays,
+ overrides: overrides) else {
// No enabled weekday in the next week (only possible from a corrupted set) , disarm rather
// than arm a misleading time the user never asked for.
ble.disableStrapAlarm()
@@ -1454,6 +1503,7 @@ final class AppModel: ObservableObject {
// @MainActor - the same Task hop the importTraceSink uses.
Self.scheduleSmartAlarmBackupNotification(minutes: behavior.smartAlarmMinutes,
weekdays: behavior.smartAlarmWeekdays,
+ overrides: overrides,
log: { [weak self] line in
Task { @MainActor in self?.live.append(log: line) }
})
@@ -1463,28 +1513,39 @@ final class AppModel: ObservableObject {
/// - `minutes`: target wake time, minutes since local midnight.
/// - `weekdays`: Calendar weekday numbers (1 = Sun … 7 = Sat) the alarm may fire on. Empty = every
/// day. Days outside 1…7 are ignored.
+ /// - `overrides`: per-weekday wake-time overrides (#554 / #1864). A weekday with an override uses
+ /// ITS OWN time instead of `minutes`; a day without one falls back to `minutes`. Only valid
+ /// entries (day 1…7, minute in [0, 1440)) count. An empty map is byte-for-byte the old path.
/// Returns the next strictly-future date matching the time on an enabled weekday, scanning today
/// plus the next 7 days, or nil if no enabled weekday falls in that range. Pure + side-effect-free
/// so it can be unit-tested against a fixed clock.
nonisolated static func nextSmartAlarmDate(minutes: Int,
weekdays: Set,
+ overrides: [Int: Int] = [:],
from now: Date = Date(),
calendar cal: Calendar = .current) -> Date? {
let valid = weekdays.filter { (1...7).contains($0) }
// An empty input means "every day" (backward compatible). A non-empty selection that filters to
// nothing (only out-of-range numbers) has no valid day to fire on, so it's nil, not a daily alarm.
if !weekdays.isEmpty && valid.isEmpty { return nil }
- let hour = minutes / 60
- let minute = minutes % 60
+ // #1864: only valid override entries (day 1…7, minute in [0, 1440)) count; a day without an
+ // override uses the default `minutes`. When the map is empty this is byte-for-byte the old path.
+ let cleanOverrides = overrides.filter { (1...7).contains($0.key) && (0..<24 * 60).contains($0.value) }
// Scan today (offset 0) through +7 days so a once-a-week alarm picked for "today, already
// passed" still resolves to the same weekday next week.
for offset in 0...7 {
- guard let day = cal.date(byAdding: .day, value: offset, to: now),
- let fire = cal.date(bySettingHour: hour, minute: minute, second: 0, of: day)
- else { continue }
+ guard let day = cal.date(byAdding: .day, value: offset, to: now) else { continue }
+ // Resolve this calendar day's weekday FIRST, so the per-day override time is applied BEFORE
+ // the strictly-future check — a later override time on today can make today's occurrence
+ // still pending (mirrors Android's `nextSmartAlarmEpochSec`).
+ let dow = cal.component(.weekday, from: day)
+ if !weekdays.isEmpty && !valid.contains(dow) { continue }
+ let wakeMin = cleanOverrides[dow] ?? minutes
+ let hour = wakeMin / 60
+ let minute = wakeMin % 60
+ guard let fire = cal.date(bySettingHour: hour, minute: minute, second: 0, of: day) else { continue }
if fire <= now { continue }
- if weekdays.isEmpty { return fire }
- if valid.contains(cal.component(.weekday, from: fire)) { return fire }
+ return fire
}
return nil
}
@@ -1671,6 +1732,17 @@ final class AppModel: ObservableObject {
}
}
+ /// The reader's temperature unit, resolved the way every screen resolves it. AppModel is not a View,
+ /// so there is no @AppStorage — but the resolution must stay identical (explicit override when set,
+ /// else derived from the unit system), or the alert banner and the Skin Temp card disagree about
+ /// what unit the same number is in.
+ private var displayTemperatureUnit: TemperatureUnit {
+ let d = UserDefaults.standard
+ let system = UnitSystem(rawValue: d.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric
+ return UnitPrefs.resolveTemperature(system: system,
+ override: d.string(forKey: UnitPrefs.temperatureKey) ?? "")
+ }
+
/// Run the `IllnessSignalEngine` from the day history + the journal-derived confounder context, then
/// publish the result + the semantic `healthAlert` banner payload.
private func applyIllnessSignal(_ days: [DailyMetric], alcohol: Bool,
@@ -1741,8 +1813,21 @@ final class AppModel: ObservableObject {
labels["hrv"] = String(localized: "HRV −\(percent)%")
}
if let r = rm({ $0.skinTempDevC }), r > 0 {
- let temperature = String(format: "%.1f", locale: AppLanguage.activeLocale, r)
- labels["skinTemp"] = String(localized: "Skin temperature +\(temperature) °C")
+ // The value is STORED in °C but must be SHOWN in the reader's unit: this label welded "°C"
+ // into the translated string, so a Fahrenheit user got "+0.7 °C" from the banner while every
+ // other surface rendered the same night as "+1.3 Δ°F".
+ //
+ // #111/#622: skinTempDevC is BIMODAL — an imported night is an ABSOLUTE wrist °C, a live one
+ // a signed DEVIATION — so neither the conversion nor the unit chip may be assumed. Kind and
+ // chip come from SkinTempDisplay, the same authority the Today and Health tiles use. The
+ // NUMBER is formatted here rather than by SkinTempDisplay because these decimals go through
+ // AppLanguage.activeLocale: a German reader sees "0,7", which the package's plain
+ // String(format:) would flatten to "0.7".
+ let temperature = UnitFormatter.skinTempSignalPhrase(
+ r,
+ fahrenheit: displayTemperatureUnit == .fahrenheit,
+ locale: AppLanguage.activeLocale)
+ labels["skinTemp"] = String(localized: "Skin temperature \(temperature)")
}
if let r = rm({ $0.respRateBpm }), let b = mean(base.compactMap { $0.respRateBpm }), r > b {
labels["respiration"] = String(localized: "Respiration up")
diff --git a/Strand/App/ContentView.swift b/Strand/App/ContentView.swift
index 43b312d241..c1d1067e7f 100644
--- a/Strand/App/ContentView.swift
+++ b/Strand/App/ContentView.swift
@@ -53,6 +53,15 @@ struct ContentView: View {
// Seed the current What's New into the Updates inbox (idempotent per version) so the bell
// collects it even if the user dismisses the auto sheet.
UpdateStore.shared.seedWhatsNewIfNeeded()
+ // #1659: NOOP is sideloaded on every platform, so nothing else will tell you a release
+ // happened. On by default and switchable off - see UpdateAvailability.defaultEnabled.
+ //
+ // Gated on the SAME condition as showWhatsNewIfDue below, matching the iOS and Android hooks:
+ // a default-on check must not reach the network during first run, before the Terms gate has
+ // been accepted. (No sideload sentence here - macOS has no seven-day re-sign and no AltStore.)
+ if onboarded && acceptedTerms == Terms.currentVersion {
+ UpdateWatch.runIfDue(currentVersion: UpdateWatch.installedVersion, sideloadHint: false)
+ }
}
.onChangeCompat(of: acceptedTerms) { _ in showWhatsNewIfDue() }
}
diff --git a/Strand/App/GpsWorkoutRecorder.swift b/Strand/App/GpsWorkoutRecorder.swift
index 5a00533429..eb6e7dfc17 100644
--- a/Strand/App/GpsWorkoutRecorder.swift
+++ b/Strand/App/GpsWorkoutRecorder.swift
@@ -254,9 +254,27 @@ enum RouteStore {
/// route has no usable polyline (so we never store an empty placeholder — honest "no route").
static func store(_ route: WorkoutRoute, startTs: Int, sport: String,
into defaults: UserDefaults = .standard) {
- guard !route.polyline.isEmpty else { return }
+ storeAll([(route, startTs, sport)], into: defaults)
+ }
+
+ /// Persist MANY routes in one blob rewrite.
+ ///
+ /// `store` reads, decodes, mutates, re-encodes and writes the whole map. That is right for the
+ /// recorder, which calls it once when a workout ends. An IMPORT calls it per workout, and the map is
+ /// capped at [maxRoutes], so N imported routes cost N full decode/encode cycles of a map that grows
+ /// to 400 entries: at roughly 7 KB per polyline (an hour at 1 Hz) a 500-workout first import churns
+ /// on the order of a gigabyte of JSON through `UserDefaults`, on a phone. Batching makes it one pass.
+ ///
+ /// Eviction is unchanged and applied ONCE after the whole batch, so an import that overflows the cap
+ /// drops the oldest by `startTs` exactly as a sequence of single stores would have — and since
+ /// imported history is older than anything recorded since, a large import evicts its own oldest
+ /// entries rather than the routes this device recorded.
+ static func storeAll(_ entries: [(route: WorkoutRoute, startTs: Int, sport: String)],
+ into defaults: UserDefaults = .standard) {
+ let usable = entries.filter { !$0.route.polyline.isEmpty }
+ guard !usable.isEmpty else { return }
var map = loadMap(from: defaults)
- map[key(startTs: startTs, sport: sport)] = route
+ for e in usable { map[key(startTs: e.startTs, sport: e.sport)] = e.route }
if map.count > maxRoutes {
// Keys lead with the startTs, so a lexicographic sort by the numeric prefix evicts the oldest.
let ordered = map.keys.sorted { lhs, rhs in
diff --git a/Strand/App/NavRouter.swift b/Strand/App/NavRouter.swift
index c0fc3e9b8f..da031e5d1f 100644
--- a/Strand/App/NavRouter.swift
+++ b/Strand/App/NavRouter.swift
@@ -29,6 +29,11 @@ final class NavRouter: ObservableObject {
case activeWorkout
case liveSession
case journal
+ /// #1862: the Coach screen, opened from the Today Coach launcher sheet once the user picks a
+ /// suggestion or submits the composer. The launcher deliberately owns no send/stream/consent
+ /// surface of its own — it hands the question to the one screen that already has them.
+ /// Also the K5 scheduled morning-brief notification's tap-through target.
+ case coach
var id: String { rawValue }
@@ -60,6 +65,8 @@ final class NavRouter: ObservableObject {
/// Ask the shell to open the Devices manager (pair / switch bands). The shell decides how.
func openDevices() { requestedDestination = .devices }
+ /// #1862: open Coach, optionally with a question the launcher already collected.
+ func openCoach() { requestedDestination = .coach }
/// Open the v5 Insights hub (the n-of-1 "what moves your Charge" surface).
func openInsightsHub() { requestedDestination = .insightsHub }
/// Open the Lab Book (private health-records logbook).
diff --git a/Strand/App/RootView.swift b/Strand/App/RootView.swift
index a7aa0483c7..b5bb0222f8 100644
--- a/Strand/App/RootView.swift
+++ b/Strand/App/RootView.swift
@@ -328,6 +328,9 @@ struct RootView: View {
case .liveSession: selection = .today
// The #627 Today journal widget routes to the Insights sidebar row (which hosts the journal card).
case .journal: selection = .insights
+ // #1862: the Today Coach card's launcher hands off here, so the send/stream/consent surface
+ // stays in exactly one place.
+ case .coach: selection = .coach
case nil: break
}
if dest != nil { router.requestedDestination = nil }
diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift
index 1ed234b393..588e83a2e4 100644
--- a/Strand/App/StrandApp.swift
+++ b/Strand/App/StrandApp.swift
@@ -22,12 +22,16 @@ struct StrandApp: App {
// Foreground presentation: without a delegate, macOS suppresses a notification's banner while the
// app is frontmost, so a reminder tested with NOOP open would show nothing. Mirrors iOS.
UNUserNotificationCenter.current().delegate = NotificationPresenter.shared
+ // K5: tapping a scheduled morning-brief notification routes to Coach via the shared NavRouter.
+ let router = NavRouter()
+ _router = StateObject(wrappedValue: router)
+ NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() }
}
@StateObject private var model = AppModel()
/// Shared cross-screen navigation hook (e.g. Live → Devices). The macOS shell (`RootView`)
/// observes it and drives the sidebar selection.
- @StateObject private var router = NavRouter()
+ @StateObject private var router: NavRouter
/// #267: drives a foreground sync kick when the window becomes active (no scenePhase hook
/// existed on macOS before this).
@Environment(\.scenePhase) private var scenePhase
diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift
index a9db87dbc5..87cc75888f 100644
--- a/Strand/BLE/BLEManager.swift
+++ b/Strand/BLE/BLEManager.swift
@@ -148,9 +148,13 @@ struct PostBondTimeoutLoopDetector {
/// The streak accumulates across the reconnect loop (a disconnect does NOT reset it) and is cleared only by
/// a genuine bond or an explicit user reconnect, exactly like BLEManager's existing `bondRefusalStreak`.
struct BondRefusalGiveUp {
- /// Consecutive bond refusals before we pause auto-reconnect + write the epitaph. 5 (not 2, where the
+ /// Consecutive bond refusals before we PAUSE auto-reconnect + write the epitaph. 5 (not 2, where the
/// pairing HINT already shows): the hint asks the user to act; we give them several reconnect cycles to
/// do it before we stop hammering. A genuinely held/stale strap reaches 5 within a couple of minutes.
+ ///
+ /// This is the AUTH-REFUSAL number specifically, and `recordRefusal` takes a per-refusal override for
+ /// that reason: an unanswered handshake asks the user for nothing, so waiting five cycles for a decision
+ /// they cannot make just buys ~4.8s link drops. See `giveUpThresholdFor`.
let giveUpThreshold: Int
private(set) var refusals = 0
@@ -162,9 +166,9 @@ struct BondRefusalGiveUp {
/// Record one bond refusal. Returns true if THIS refusal freshly crossed the give-up threshold (so the
/// caller pauses the reconnect + writes the epitaph exactly once).
- mutating func recordRefusal() -> Bool {
+ mutating func recordRefusal(threshold: Int? = nil) -> Bool {
refusals += 1
- if !gaveUp && refusals >= giveUpThreshold {
+ if !gaveUp && refusals >= (threshold ?? giveUpThreshold) {
gaveUp = true
return true
}
@@ -191,6 +195,48 @@ struct BondRefusalGiveUp {
"NOOP stopped retrying because your strap keeps refusing to pair. It is likely still held by the official WHOOP app, or your phone is holding an old pairing. Close the WHOOP app, put the strap in pairing mode (tap until the LEDs flash blue), and if it is listed in your Bluetooth settings choose Forget This Device. Then tap Connect to try again."
}
+ /// #1635: the log epitaph for the SUPPRESSION path.
+ ///
+ /// Separate from [epitaphLine] because that one asserts a cause ("almost certainly held by the
+ /// official WHOOP app") that only an auth refusal supports. Reusing it here would print a confident
+ /// explanation for a write that simply vanished.
+ ///
+ /// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.helloSuppressedEpitaph`.
+ static func helloSuppressedEpitaph(refusals: Int, opaqueId: String) -> String {
+ "Bond epitaph: the strap [\(opaqueId)] never acknowledged the secure handshake \(refusals)x in a row, and the attempt is what drops the link - leaving the handshake off so live heart rate keeps streaming. Tap Connect to try it again."
+ }
+
+ /// #1635: the hint shown when the hello is switched off and the link is KEPT.
+ ///
+ /// Nothing is paused on this branch, so it must not say auto-reconnect stopped. It names what was lost
+ /// and what to try, without asserting a cause.
+ ///
+ /// #1635 follow-up: it used to end "Tap Connect to try the handshake again", which framed a strap that
+ /// REFUSES pairing as a retryable failure — inviting exactly the hammering the give-up latch exists to
+ /// stop. A field report read all of this and still asked how to fix it, so the ending now leads with
+ /// the only thing anyone has recovered from this state with (pairing mode, reported once on #1635 and
+ /// hedged accordingly) and keeps Connect as the follow-up. It also no longer claims HRV and resting
+ /// heart rate are unavailable: since #1884 an HR-only night reports both.
+ ///
+ /// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.helloSuppressedHint`.
+ static func helloSuppressedHint() -> String {
+ "The secure handshake with your strap never completes, and the attempt itself is what drops the link. NOOP has switched it off for this strap so live heart rate keeps streaming. History sync stays unavailable until it pairs, and so do motion, skin temperature, SpO₂ and respiratory rate, so sleep is staged from heart rate alone. Some straps have paired again after being put in pairing mode. Tap until the LEDs flash blue, then tap Connect."
+ }
+
+ /// The paused hint for a bond that failed WITHOUT the strap ever answering (#1635).
+ ///
+ /// [pausedHint] names a cause — the strap still held by the official WHOOP app, or a stale OS pairing —
+ /// which is well founded when the refusal arrived as INSUFFICIENT_AUTHENTICATION or
+ /// INSUFFICIENT_ENCRYPTION: the strap actively said no. It is NOT founded when the CLIENT_HELLO simply
+ /// goes unanswered and the link drops on a timer, which is a different observation with several
+ /// possible causes. Telling that user to close the WHOOP app would be a guess dressed as instruction,
+ /// and if it is wrong they have no way to know.
+ ///
+ /// Pure. Byte-identical to the Kotlin `BondRefusalGiveUp.pausedHintHandshakeUnanswered`.
+ static func pausedHintHandshakeUnanswered() -> String {
+ "NOOP stopped retrying because the secure handshake with your strap never completes: the strap does not answer, and the link drops a few seconds later. Auto-reconnect is paused so it stops draining both batteries. Tap Connect to try again, and if it keeps happening please share your strap log."
+ }
+
/// #750: a short OPAQUE token from a CoreBluetooth-local peripheral UUID for the epitaph. The CB UUID is
/// per-install (NOT the hardware MAC), and we keep only its first 8 hex chars, so the token is stable
/// within a log but carries no device-identifying PII. Pure + deterministic. Twin of the Android helper.
@@ -507,6 +553,17 @@ public final class BLEManager: NSObject, ObservableObject {
static let disService = CBUUID(string: "180A")
static let disSerialChar = CBUUID(string: "2A25") // Serial Number String
static let disHwRevChar = CBUUID(string: "2A27") // Hardware Revision String
+ static let disFwRevChar = CBUUID(string: "2A26") // Firmware Revision String
+ static let disManufacturerChar = CBUUID(string: "2A29") // Manufacturer Name String
+ static let disModelNumberChar = CBUUID(string: "2A24") // Model Number String
+ static let disSwRevChar = CBUUID(string: "2A28") // Software Revision String
+ /// Every DIS characteristic NOOP reads, in ONE place. The failure reporter tests membership here, so
+ /// a characteristic added beside these cannot go silently unreported — the exact "a refused read is
+ /// indistinguishable from one never issued" gap the reporter exists to close. Kotlin twin: DIS_CHARS.
+ static let disChars: Set = [
+ disSerialChar, disHwRevChar, disFwRevChar,
+ disManufacturerChar, disModelNumberChar, disSwRevChar,
+ ]
static let restoreID = "com.openwhoop.ble.central"
@@ -515,6 +572,11 @@ public final class BLEManager: NSObject, ObservableObject {
private let router: FrameRouter
private var collector: Collector?
/// #716: stored on bootstrap so the scan callback can fix the seeded "WHOOP" model label.
+ /// #1303: fired after the strap's history has been re-pointed onto its stable `whoop-` id.
+ /// The live persist paths are already re-pointed inline by then; this exists so the OBSERVABLE spine
+ /// (`DeviceRegistry`, and the coordinator that watches it) follows too — the store write above is not
+ /// observable, so without this the UI would keep showing the old id until relaunch.
+ var onSerialIdentityAdopted: ((String) -> Void)?
private var registryStore: DeviceRegistryStore?
/// #716: true once the seeded "WHOOP" model has been stamped to the correct family.
private var modelStamped = false
@@ -583,6 +645,13 @@ public final class BLEManager: NSObject, ObservableObject {
/// Pure battery-adaptive gate (#477), the twin of Android `WhoopBleClient.idleThrottleActive`. Keyed
/// on the STRAP's battery: armed by `thresholdPct` > 0, engages while the strap is discharging at/below
/// `thresholdPct`. The phone's own Low Power Mode deliberately does NOT trigger it. Charging never engages.
+ ///
+ /// `charging` is not purely "is charging" (#1935): on a 5/MG it is also set on pack ATTACH, so a
+ /// depleted or badly-seated pack reads true while nothing charges, and this gate then stays off. The
+ /// window is bounded — the next BATTERY_LEVEL rewrites the flag from the strap's own gauge — and the
+ /// reasoning for accepting that rather than splitting the state is on `LiveState.charging`. Read it
+ /// before adding a trend check here: making this wait for a rising gauge would delay throttle release
+ /// on every honest attach to close an edge that already closes itself.
static func lowPowerThrottleActive(batteryPct: Int, charging: Bool, thresholdPct: Int) -> Bool {
thresholdPct > 0 && !charging && batteryPct <= thresholdPct
}
@@ -671,6 +740,29 @@ public final class BLEManager: NSObject, ObservableObject {
/// DIAGNOSTIC ONLY — it never cancels or retries; recovery is separate work.
private var pendingConnectProbe: DispatchWorkItem?
static let pendingConnectProbeSeconds: TimeInterval = 15
+ /// #1809: per-connection inbound accounting for the link epitaph. A strap log could not say whether
+ /// the strap transmitted anything; these three make it a measurement. Reset in `didConnect`, so they
+ /// describe THIS link and never carry a previous session's traffic into its epitaph.
+ private var inboundFrames = 0
+ private var inboundBytes = 0
+ private var cmdChannelFrames = 0
+ /// #1635: rows ACCEPTED on this link, split by PATH. The realtime decoder yields only hr/rr/events/
+ /// battery; gravity, resp, skinTemp, SpO2 and steps arrive solely through the offload, so counting one
+ /// path and naming streams from the other prints a constant rather than a finding. Cleared with the
+ /// frame counters. Plain vars like their neighbours: both feeders hand up on the main actor.
+ private var liveHr = 0
+ private var liveRr = 0
+ private var offloadHr = 0
+ private var offloadRr = 0
+ private var offloadGravity = 0
+ private var offloadResp = 0
+ private var offloadSkinTemp = 0
+ private var offloadSpo2 = 0
+ /// Chunks the offload actually persisted on this link — separates "never ran" from "nothing new".
+ private var offloadChunks = 0
+
+ /// Uptime clock for the epitaph. Monotonic, so a wall-clock change mid-link cannot make it negative.
+ private var linkUpSince: DispatchTime?
/// Last time ANY notification arrived — drives the liveness watchdog.
private var lastDataAt = Date()
/// True while a Live/Health screen is on-screen and wants the realtime stream. One of the two
@@ -801,6 +893,8 @@ public final class BLEManager: NSObject, ObservableObject {
/// Re-entrancy guard for captureRawAccel: true while a bounded on-demand window is running.
/// A second tap is a no-op until the active capture's asyncAfter block fires and clears this.
private var rawCaptureInFlight = false
+ private var rawCaptureStoppedAt = Date.distantPast
+ private var unexpectedImuStopAt = Date.distantPast
/// Ordered queue of frames awaiting drain through the serial Backfiller task.
private var backfillFrameQueue: [[UInt8]] = []
/// True while the drain task is running (prevents a second drain task from launching).
@@ -895,9 +989,11 @@ public final class BLEManager: NSObject, ObservableObject {
private var dataNotifyCharacteristic: CBCharacteristic?
private var heartRateCharacteristic: CBCharacteristic?
private var batteryCharacteristic: CBCharacteristic?
- /// #520 DIS: remembered at discovery, READ post-bond (see the #490 note — a 5/MG refuses standard
- /// reads before the link is encrypted). Serial + hardware revision are immutable, so they are read
- /// ONCE per connection (`disRead`), never re-polled like the battery.
+ /// #520 DIS: remembered at discovery, READ post-bond — and since #1635 also on a SUPPRESSED link that
+ /// will never bond, which is now a permanent state rather than a transient one. The #490 note (a 5/MG
+ /// refuses standard reads before the link is encrypted) is the open question that attempt settles
+ /// either way. Serial + hardware revision are immutable, so they are read ONCE per connection
+ /// (`disRead`), never re-polled like the battery.
/// Peripheral whose GATT tree has already been dumped, so the ~30-line enumeration is emitted once per
/// device rather than once per connect (#1635). Not persisted: a fresh launch is exactly when the tree
/// is worth seeing again, and a strap whose firmware changed between runs may expose something new.
@@ -906,8 +1002,40 @@ public final class BLEManager: NSObject, ObservableObject {
private var disSerialCharacteristic: CBCharacteristic?
private var disHwRevCharacteristic: CBCharacteristic?
private var disRead = false
+ /// 3.0s — the Kotlin twin's `BATTERY_ON_CONNECT_DELAY_MS * 2` (1500 × 2), and the same 1.5s on-connect
+ /// pacing this file already uses before `requestSync(.connect)`. Long enough that the link has settled
+ /// after the suppression decision, short enough to land in the same session. Pinned to the twin's
+ /// number rather than an independently chosen one: this read can cost the link, so the two platforms
+ /// should not be probing at different moments when a field capture has to explain a drop.
+ static let unbondedDisReadDelay: TimeInterval = 3.0
+ /// #1193: the WHOOP 4.0 strap serial from `GET_HELLO_HARVARD`, once it has been seen TWICE.
+ ///
+ /// The withholding rule itself lives in `RepeatedSerialGate`, beside the decoder, where it is unit
+ /// tested; this pair is just the manager's view of its outcome.
+ ///
+ /// The 5/MG adopts its DIS serial on first read, because `0x2A25` is a spec-defined field that means
+ /// one thing. The 4.0 offset is not that: it was read off a single capture, so "the 9-char alnum run
+ /// at offset 14" is a strong inference rather than a documented field. If that run turned out to be
+ /// per-session rather than per-strap, adopting it immediately would mint a NEW id on every connect and
+ /// migrate the history each time — strictly worse than the duplicate row this exists to prevent.
+ ///
+ /// So a value must repeat before it is trusted. Two hellos carrying the same run cannot both be a
+ /// fresh per-session token, which is the only failure mode that would do real damage. The cost is that
+ /// a 4.0 adopts one connect later than a 5/MG; the benefit is that the destructive failure cannot
+ /// happen at all. Deliberately NOT reset on disconnect — the two sightings are meant to span connects.
+ private var harvardSerialGate = RepeatedSerialGate()
+ private var harvardSerialConfirmed: String?
+
private var disSerial: String?
private var disHwRev: String?
+ /// #1635 follow-up: the DIS identity extras (firmware, manufacturer, model, software revision) as
+ /// discovered. Held as a list rather than one property each because nothing branches on them
+ /// individually — they are read as a set and reported as a set.
+ private var disExtraCharacteristics: [CBCharacteristic] = []
+ /// The DIS firmware string (0x2A26), which is the ONLY firmware source an unbonded 5/MG has: the
+ /// puffin report needs the bond that never happens. Published only as a fallback, never as an
+ /// override — see `shouldPublishDisFirmware`.
+ private var disFirmware: String?
/// EXPERIMENTAL WHOOP 5.0/MG puffin notify chars (fd4b0003/4/5/7), remembered at discovery so we
/// can re-subscribe them AFTER bonding — the strap refuses them ("Authentication is insufficient")
/// until the link is encrypted (issue #17).
@@ -915,6 +1043,11 @@ public final class BLEManager: NSObject, ObservableObject {
private var reassembler = Reassembler()
private var seq: UInt8 = 0
private var didBond = false
+ /// #1635: one explicit Connect grants one fresh CLIENT_HELLO even when the suppression latch is set.
+ /// Consumed unconditionally by the write site, so the retry belongs to THIS session — leaving it set
+ /// would hand a stale retry to some later automatic reconnect and restart the loop the suppression
+ /// exists to end. Kotlin twin: `WhoopBleClient.helloRetryRequested`.
+ private var helloRetryRequested = false
/// WHOOP 5/MG only: realtime HR has been armed (puffin TOGGLE_REALTIME_HR sent) once for this
/// connection, so the post-bond callback re-firing on later `.withResponse` writes doesn't re-send it.
private var whoop5RealtimeArmed = false
@@ -1020,6 +1153,7 @@ public final class BLEManager: NSObject, ObservableObject {
/// `connectFromSystem`'s targeted path already makes: no new outbound command, nothing written to the
/// strap, so the BLE safety contract is unaffected. Twin of `OuraLiveSource.issueStandingConnect`.
private func issueStandingConnect(whilePausedForBondLoop: Bool = false) {
+ guard whoopConnectAllowed("standing-connect") else { return }
guard !intentionalDisconnect else { return }
// #1539: the bond-loop pause suppresses this by default, but the paused paths deliberately opt in —
// a parked, timeout-free connect is what lets that pause end without the user.
@@ -1129,9 +1263,40 @@ public final class BLEManager: NSObject, ObservableObject {
/// 4.0, or an unidentified strap all read false.
var isWhoop5MG: Bool { whoop5Variant.isMG }
+ /// Whether a WHOOP is the device the user actually selected (#1881). FAIL-OPEN: `true` unless
+ /// something positively says otherwise, so the single-WHOOP path and every unknown state behave
+ /// exactly as before — a wrong `false` here would stop the strap connecting for everyone.
+ ///
+ /// This is the DURABLE form of the coordinator's `stopWhoop()`. That call is edge-triggered — it
+ /// fires once on the WHOOP→other-source transition and nothing re-asserts it — while the WHOOP flow
+ /// has system-driven entry points that consult nobody: `poweredOn` (every Bluetooth toggle and
+ /// `bluetoothd` restart), state restoration, and the standing connect CoreBluetooth honours while
+ /// the app is suspended. So a radio toggle resurrected a strap the user had switched away from,
+ /// drained its history, and — before the identity fix below — filed every row under the ACTIVE
+ /// device's id. Reported with a proven case: 770 `stepSample` rows under an Oura ring, which has no
+ /// pedometer.
+ ///
+ /// Set from the same two closures the coordinator already uses to stop and start WHOOP, so the
+ /// deliberate exception survives untouched: an Apple Watch becoming active must NOT stop a live
+ /// WHOOP, and `switchToAppleWatch` calls neither closure.
+ private var whoopIsActiveDevice = true
+
+ /// The last entry point `whoopConnectAllowed` turned away, so the block is logged once per path
+ /// rather than on every tick of the family-rotation timer.
+ private var lastBlockedConnectReason: String?
+
+ /// #1635: one `ScanAdvertisementSummary` line per SCAN, not per process — the question it answers
+ /// is only visible by comparing a scan before the strap was put in pairing mode against one after,
+ /// so `startScan` reopens it.
+ private var advertisementLogged = false
+
/// Stable device id; matches the server's existing device for sync parity. Overridable.
/// Seeded from the init argument, then refined once in bootstrapStore() to the device registry's
/// active id (still "my-whoop" today) before any store writes use it — see bootstrapStore().
+ ///
+ /// #1881: this is "the device these bytes came from", NOT "the active device". Those were the same
+ /// thing only while every registered device was a WHOOP; `adoptSourceIdentity(for:)` re-points it
+ /// per connection from the peripheral that actually connected.
private(set) var deviceId: String
/// Captured (device↔wall) correlation from GET_CLOCK; nil until the response lands.
private(set) var clockRef: ClockRef?
@@ -1156,7 +1321,10 @@ public final class BLEManager: NSObject, ObservableObject {
// before any BLE data arrives.
self.collector = nil
super.init()
- state.lastSyncedAt = UserDefaults.standard.object(forKey: "lastSyncedAt") as? Double
+ // Deliberately NOT seeded from the global key here. It belongs to whichever strap synced last,
+ // which on a two-strap install is not the one the screens are scoped to — the misattribution this
+ // whole change removes. `seedLastSyncFromActiveStrap` fills it from the ACTIVE strap once the
+ // registry exists (bootstrapStore); a blank moment is honest, another strap's timestamp is not.
// Restore identifier + background-capable central (foundation for M3 state restoration).
#if os(iOS)
// iOS background state preservation/restoration: the restore identifier is what makes
@@ -1170,12 +1338,47 @@ public final class BLEManager: NSObject, ObservableObject {
#endif
// Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync.
router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) }
+ router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193
// #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground.
installForegroundSalvageProbe()
}
/// Build the WhoopStore + Collector + Backfiller asynchronously. Safe to call multiple
/// times — bails out early if the collector is already initialised.
+ /// Seed the last-sync display from the ACTIVE strap's own stamp — PR #556's intent, correctly attributed.
+ ///
+ /// Resolved against the registry's active row, exactly as the debug export resolves firmware. NOT from
+ /// the legacy global key, which belongs to whichever strap synced last: on a two-strap install that is
+ /// not the strap the screens are scoped to, and seeding from it would put one strap's sync time on the
+ /// other's Today screen — the defect this change exists to remove.
+ ///
+ /// The legacy global still applies when exactly one strap is paired, because only then can it not be
+ /// ambiguous (see `LastSyncAttribution.resolve`). Called from `bootstrapStore` because that is the
+ /// first point the registry exists; the initialisers deliberately seed nothing.
+ ///
+ /// Never overwrites a value this session has already earned: a HISTORY_COMPLETE landing while this
+ /// runs is newer than anything persisted and must win.
+ private func seedLastSyncFromActiveStrap(registry: DeviceRegistryStore) {
+ // Registry + defaults resolved off-actor, then a single hop to publish. The `lastSyncedAt == nil`
+ // check lives INSIDE that hop and nowhere else: checking it out here as well would be a
+ // check-then-act across an await, and the value it guards is exactly the one a HISTORY_COMPLETE
+ // can set while this runs.
+ let rows = (try? registry.all()) ?? []
+ let activeId = (try? registry.activeDeviceId()) ?? nil
+ let activeAddr = rows.first(where: { $0.id == activeId })?.peripheralId
+ let d = UserDefaults.standard
+ guard let seed = LastSyncAttribution.resolve(
+ perDevice: LastSyncAttribution.prefKey(peripheralId: activeAddr)
+ .flatMap { d.object(forKey: $0) as? Double },
+ legacyGlobal: d.object(forKey: "lastSyncedAt") as? Double,
+ pairedCount: rows.count) else { return }
+ Task { @MainActor [state] in
+ // Never overwrite a value this session earned: a HISTORY_COMPLETE landing while the registry
+ // read was in flight is newer than anything persisted, and must win.
+ if state.lastSyncedAt == nil { state.lastSyncedAt = seed }
+ }
+ }
+
func bootstrapStore() async {
guard collector == nil else { return }
// Surface store-open failures instead of swallowing them with `try?` (#222): a silent failure
@@ -1205,9 +1408,25 @@ public final class BLEManager: NSObject, ObservableObject {
// its own concurrency).
let registry = DeviceRegistryStore(dbQueue: store.registryWriter)
self.registryStore = registry
- if let activeId = try? registry.activeDeviceId(),
- !activeId.isEmpty {
- self.deviceId = activeId
+ seedLastSyncFromActiveStrap(registry: registry)
+ if let activeId = try? registry.activeDeviceId(), !activeId.isEmpty {
+ // #1881: adopt the active id ONLY when the active device is a WHOOP. This is the line the
+ // report quotes, and its comment stated the assumption that falsified it — with a ring active
+ // this set `deviceId` to the RING, and every live sample and historical chunk persisted under
+ // it. `adoptSourceIdentity` corrects the id when a strap connects, but not for the window
+ // between here and that connect, so the wrong value must not be adopted in the first place.
+ //
+ // Fail-open on an unknown row (nil): unchanged behaviour for anything the registry can't
+ // classify. Only a POSITIVELY non-WHOOP active device is refused, and that same fact seeds the
+ // connect gate — which closes the launch race where `poweredOn` can reach the WHOOP flow
+ // before `SourceCoordinator` has wired up and asserted it.
+ let activeRow = (try? registry.all())?.first(where: { $0.id == activeId })
+ if let activeRow, !SourceIdentity.isWhoop(activeRow) {
+ setWhoopIsActiveDevice(false)
+ log("Active device \(activeId) is not a WHOOP — leaving sample attribution on \(deviceId) (#1881)")
+ } else {
+ self.deviceId = activeId
+ }
}
// Look up the active device's real brand/model instead of a hardcoded string — this predated
// multi-device support and mislabeled every non-"WHOOP 4.0" device (a WHOOP 5.0/MG strap, an Oura
@@ -1219,7 +1438,12 @@ public final class BLEManager: NSObject, ObservableObject {
// persists raw frames. Flip "enableRawCapture" in UserDefaults to capture raw again.
let enableRawCapture = UserDefaults.standard.bool(forKey: "enableRawCapture")
collector = Collector(store: store, deviceId: deviceId,
- enableRawCapture: enableRawCapture)
+ enableRawCapture: enableRawCapture,
+ log: { [weak self] line in self?.log(line) },
+ onBanked: { [weak self] c in
+ // Live path: hr/rr are all the realtime decoder yields.
+ self?.liveHr += c.hr; self?.liveRr += c.rr
+ })
// The store can finish bootstrapping AFTER connect(model:) already ran (both wait on
// poweredOn), so apply the family/clock configuration here too — whichever runs last wins.
configureCollectorFamily()
@@ -1227,6 +1451,13 @@ public final class BLEManager: NSObject, ObservableObject {
ackTrim: { [weak self] trim, endData in
self?.ackHistoricalChunk(trim: trim, endData: endData)
},
+ onBankedOffload: { [weak self] c in
+ guard let self else { return }
+ self.offloadChunks += 1
+ self.offloadHr += c.hr; self.offloadRr += c.rr
+ self.offloadGravity += c.gravity; self.offloadResp += c.resp
+ self.offloadSkinTemp += c.skinTemp; self.offloadSpo2 += c.spo2
+ },
enableRawCapture: enableRawCapture,
log: { [weak self] s in self?.log(s) },
rejectedSink: { [weak self] frames, trim, family in
@@ -1292,7 +1523,10 @@ public final class BLEManager: NSObject, ObservableObject {
self.router = FrameRouter(state: state)
self.collector = collector
super.init()
- state.lastSyncedAt = UserDefaults.standard.object(forKey: "lastSyncedAt") as? Double
+ // Deliberately NOT seeded from the global key here. It belongs to whichever strap synced last,
+ // which on a two-strap install is not the one the screens are scoped to — the misattribution this
+ // whole change removes. `seedLastSyncFromActiveStrap` fills it from the ACTIVE strap once the
+ // registry exists (bootstrapStore); a blank moment is honest, another strap's timestamp is not.
// Restore identifier + background-capable central (mirrors the production initializer
// so a restored manager matches by identifier; only exercised by tests/previews).
#if os(iOS)
@@ -1304,12 +1538,19 @@ public final class BLEManager: NSObject, ObservableObject {
#endif
// Strap-as-clock: an incoming EVENT packet kicks a rate-limited catch-up sync.
router.onSyncTrigger = { [weak self] in self?.requestSync(.strap) }
+ router.onStrapSerial = { [weak self] serial in self?.noteHarvardSerial(serial) } // #1193
// #78 hole-4: a paused-for-bond-loop strap gets one bounded salvage attempt per app-foreground.
installForegroundSalvageProbe()
}
// MARK: Public API
+ /// Give buffered standard-HR rows a persistence attempt before app suspension/termination.
+ /// Kept on BLEManager so the app lifecycle reaches the live Collector owned by this connection.
+ func flushStandardHRForLifecycle(reason: LivePersistTrace.StandardHRFlushReason) async {
+ await collector?.flushStandardHR(reason: reason)
+ }
+
/// USER-initiated connect (the Connect button, the scan flow, Add-a-WHOOP). The ONLY entry that
/// re-arms a bond-loop give-up: a user gesture is an explicit "try again", so the streak + pause
/// clear and auto-reconnect works again if it bonds. System-initiated paths (Bluetooth poweredOn,
@@ -1325,6 +1566,17 @@ public final class BLEManager: NSObject, ObservableObject {
autoReconnectPausedForBondLoop = false
bondLoopPausedAt = nil
}
+ // #1635: the suppression path pauses NOTHING, so the reset above never fires for it. An explicit
+ // Connect is exactly the signal that the user has acted (pairing mode, closed the WHOOP app), so
+ // it must grant a fresh handshake regardless. Be exact about what that does and does not do: it
+ // grants ONE connect's worth of hello and leaves the latch standing, so if the strap still will
+ // not answer, the automatic reconnects behind this attempt stay suppressed. The latch is cleared
+ // by the attempt SUCCEEDING (didWriteValueFor's genuine ack) or by forgetDevice, never by the tap
+ // itself — Kotlin `pairingHintClearDropsSuppressionLatch` is the twin of that rule, and it had to
+ // be made one: clearing on the tap there re-paid the whole give-up on every press.
+ // Deliberately NOT in connectCore: a system-initiated reconnect must not.
+ helloRetryRequested = true
+ bondGiveUp.reset()
connectCore(model: model)
}
@@ -1335,6 +1587,12 @@ public final class BLEManager: NSObject, ObservableObject {
/// path schedules nothing afterwards, so the hammer loop cannot restart. A genuine bond still fully
/// resets via the didWriteValueFor path, so a strap freed since the give-up self-heals.
func connectFromSystem(model: WhoopModel = .persisted) {
+ // #1881: the gate belongs HERE, not in the shared `connectCore`. This file already draws the line
+ // the fix needs — `connect()` is the user's explicit Connect button, and every system-initiated
+ // path "MUST use connectFromSystem()" — and the report's complaint is only ever about NOOP acting
+ // on its own. Gating the shared core instead would have made the Connect button silently dead
+ // while the Devices screen showed a "Reconnecting…" toast.
+ guard whoopConnectAllowed("connect-from-system") else { return }
connectCore(model: model)
}
@@ -1353,6 +1611,7 @@ public final class BLEManager: NSObject, ObservableObject {
// and tell the router which decoder to use. Fresh per connection so no stale bytes carry over.
reassembler = Reassembler(family: model.deviceFamily)
router.family = model.deviceFamily
+ router.deviceId = deviceId // #1706: attribute this connection's alarm readback
// Live 5/MG persistence: point the Collector's decode at the selected family and install the
// identity clock ref for a 5/MG (its live timestamps are already real unix). WHOOP 4.0 keeps
// the GET_CLOCK correlation flow untouched. Re-applied after bootstrapStore builds the
@@ -1447,6 +1706,9 @@ public final class BLEManager: NSObject, ObservableObject {
public func forgetDevice(_ peripheralId: String?) {
let target = peripheralId.flatMap { UUID(uuidString: $0) }
let isCurrent = target == nil || peripheral?.identifier == target
+ // Captured BEFORE the teardown below nils `peripheral`, since a nil argument means "the current
+ // strap" and that is the only place its identifier can still be read.
+ let releasedUUID = peripheralId ?? peripheral?.identifier.uuidString
intentionalDisconnect = true // defuses the disconnect→3s-reconnect loop's guard
cancelScanFallback()
readoptingTo = nil // abandon any in-flight #52 pin handoff
@@ -1467,6 +1729,12 @@ public final class BLEManager: NSObject, ObservableObject {
}
// #747/#750 invariant: releasing a strap fully resets the give-up + pause (like disconnect()) so
// a paused state can never outlive the strap it belonged to and wedge a later re-add.
+ //
+ // #1635: the hello-suppression latch is exactly that kind of state, and it is PERSISTED - so
+ // without this it outlives the strap it belonged to, silently suppressing the handshake on a
+ // re-add and leaving a stale key behind for a strap the user removed. Re-latching costs the same
+ // five refusals it always did, so a strap that genuinely cannot bond is no worse off.
+ HelloSuppressionStore.setSuppressed(releasedUUID, false)
bondGiveUp.reset()
autoReconnectPausedForBondLoop = false
bondLoopPausedAt = nil
@@ -1701,6 +1969,84 @@ public final class BLEManager: NSObject, ObservableObject {
deviceId = id
collector?.deviceId = id
backfiller?.deviceId = id
+ // #1881: the alarm readback attributes through the router's own copy (#1706), which this used to
+ // leave pointing at the previous device. Same field, same conflation, one more consumer.
+ router.deviceId = id
+ }
+
+ /// Record whether a WHOOP is the active device (#1881). Called from the SAME two closures the
+ /// `SourceCoordinator` already uses to stop and start the WHOOP, so it inherits their semantics
+ /// exactly — including the deliberate Apple Watch exception, which calls neither.
+ ///
+ /// Re-asserts itself: if a WHOOP link is already up when a different source becomes active, the
+ /// coordinator's own `stopWhoop()` drops it: this only stops it coming back on its own.
+ public func setWhoopIsActiveDevice(_ active: Bool) {
+ guard whoopIsActiveDevice != active else { return }
+ whoopIsActiveDevice = active
+ log(active
+ ? "WHOOP is the active device again — scanning and reconnecting are allowed"
+ : "WHOOP is no longer the active device — not scanning or connecting until it is again (#1881)")
+ }
+
+ /// The one gate every WHOOP scan/connect entry point passes through (#1881).
+ ///
+ /// Deliberately at the CONNECT sites rather than only at `connectCore`: there are five
+ /// `central.connect` sites, and `connectCore` owns two. The rest are reached from `poweredOn` and
+ /// `willRestoreState` (via `connectRestored`), from `didDiscover`, and from the standing connect —
+ /// which involves no scan at all and is honoured while the app is suspended, so no scan-side guard
+ /// can cover it.
+ ///
+ /// `reason` names the entry point in the strap log, so a "why did my ring's data grow legs" report
+ /// says which path tried. Logged only on the transition into blocking, so a rotation timer cannot
+ /// flood the log.
+ private func whoopConnectAllowed(_ reason: String) -> Bool {
+ if whoopIsActiveDevice { return true }
+ // The flag is a CACHE of a registry fact, and the registry is the authority. Re-validate before
+ // refusing, so the gate can never latch: it is set from the coordinator's stop/start closures and
+ // seeded at bootstrap, and if those ever disagree — the coordinator failing to wire after the seed
+ // said "not a WHOOP", say — a stale `false` would stop the strap connecting for the whole session.
+ // Only on the blocked path, so the common case still costs nothing.
+ if let registry = registryStore, let activeId = try? registry.activeDeviceId() {
+ let row = (try? registry.all())?.first(where: { $0.id == activeId })
+ if row.map(SourceIdentity.isWhoop) ?? true {
+ setWhoopIsActiveDevice(true)
+ return true
+ }
+ }
+ if lastBlockedConnectReason != reason {
+ lastBlockedConnectReason = reason
+ log("Not connecting the WHOOP (\(reason)): a different device is active (#1881)")
+ }
+ return false
+ }
+
+ /// Attribute this connection to the strap that ACTUALLY connected, not to whatever device is active
+ /// (#1881). Resolves the registry row whose `peripheralId` matches the connected peripheral and
+ /// re-points `deviceId` + the in-flight `Collector`/`Backfiller`/router to it.
+ ///
+ /// Conservative by construction — it only ever moves the id to a row that is BOTH a WHOOP and
+ /// matched by peripheral:
+ /// • no registry / unreadable → leave the id alone (today's behaviour).
+ /// • no row matches this peripheral → leave it alone. This is the legacy single-WHOOP case before
+ /// the row has adopted a `peripheralId`; `SourceCoordinator.connectedPeripheralChanged` adopts it
+ /// on this same connect, so the following one resolves.
+ /// • the matched row is not a WHOOP → leave it alone; nothing else reaches this delegate.
+ /// • already correct → no write.
+ ///
+ /// Known narrow residual: the disconnect handler flushes buffered 0x2A37 HR in a deliberately
+ /// fire-and-forget `Task`, and the Collector reads `deviceId` at PERSIST time — so a flush still in
+ /// flight when the next link re-points the id would attribute the previous link's tail to the new
+ /// strap. It needs a re-point to happen at all, which only occurs when the resolved row differs from
+ /// the current id: never on a settled single-WHOOP install. In the reported case the move is from the
+ /// ring to the strap, and those buffered rows are the STRAP's, so the new attribution is the correct
+ /// one. The genuinely wrong case is two WHOOPs where A's tail lands on B — a pre-existing hazard of
+ /// `setActiveDeviceId`, which the coordinator already calls on a WHOOP→WHOOP switch.
+ private func adoptSourceIdentity(for peripheral: CBPeripheral) {
+ guard let registry = registryStore, let rows = try? registry.all() else { return }
+ guard let resolved = SourceIdentity.resolve(address: peripheral.identifier.uuidString,
+ rows: rows, currentId: deviceId) else { return }
+ log("Attributing this link to \(resolved) — the strap that connected, not the active device (#1881)")
+ setActiveDeviceId(resolved)
}
/// Add-a-WHOOP wizard: scan the selected family's WHOOP service and surface every nearby strap in
@@ -1756,8 +2102,14 @@ public final class BLEManager: NSObject, ObservableObject {
rawCaptureInFlight = true
let secs = RawCaptureWindow.clamp(seconds)
collector?.beginRawCapture(seconds: secs)
- send(.startRawData, payload: [0x01])
- send(.toggleIMUMode, payload: [0x01])
+ // Hardware-verified on WHOOP 5/MG: opcode 106 alone acknowledges but does not start the
+ // producer. START_RAW_DATA must precede the two-byte realtime IMU selector.
+ send(.startRawData, payload: [0x01], writeType: .withResponse)
+ if selectedModel.deviceFamily == .whoop5 {
+ send(.toggleIMUMode, payload: [0x01, 0x01], writeType: .withResponse)
+ } else {
+ send(.toggleIMUMode, payload: [0x01], writeType: .withResponse)
+ }
log("Raw-accel capture: started for \(secs)s")
DispatchQueue.main.asyncAfter(deadline: .now() + secs) { [weak self] in
guard let self else { return }
@@ -1765,7 +2117,10 @@ public final class BLEManager: NSObject, ObservableObject {
// continuous stream must keep running — we just flush/upload the bounded window we
// captured without halting the wider session.
if !UserDefaults.standard.bool(forKey: "enableRawCapture") {
- self.send(.stopRawData, payload: [0x01])
+ self.send(.stopRawData, payload: [0x01], writeType: .withResponse)
+ if self.selectedModel.deviceFamily == .whoop5 {
+ self.send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse)
+ }
}
self.rawCaptureInFlight = false
Task { @MainActor in
@@ -1775,6 +2130,50 @@ public final class BLEManager: NSObject, ObservableObject {
}
}
+ /// Start a manually stopped 5/MG raw-data session. Returns false when another capture is active.
+ @discardableResult
+ public func startGroundTruthRawCapture(sessionId: String) -> Bool {
+ guard !rawCaptureInFlight else { return false }
+ rawCaptureInFlight = true
+ send(.startRawData, payload: [0x01], writeType: .withResponse)
+ send(.toggleIMUMode,
+ payload: selectedModel.deviceFamily == .whoop5 ? [0x01, 0x01] : [0x01],
+ writeType: .withResponse)
+ log("Raw-data session: started")
+ return true
+ }
+
+ /// Stop and flush the current manually controlled raw-data session.
+ public func stopGroundTruthRawCapture() async {
+ if rawCaptureInFlight && !UserDefaults.standard.bool(forKey: "enableRawCapture") {
+ send(.stopRawData, payload: [0x01], writeType: .withResponse)
+ if selectedModel.deviceFamily == .whoop5 {
+ send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse)
+ }
+ }
+ rawCaptureInFlight = false
+ rawCaptureStoppedAt = Date()
+ log("Raw-data session: stopped + flushed")
+ }
+
+ /// Stop a realtime IMU producer left armed after a crash, lost stop write, or another client.
+ private func stopUnexpectedRealtimeImu(_ frame: [UInt8], isOffload: Bool, now: Date = Date()) {
+ guard selectedModel.deviceFamily == .whoop5, !isOffload, frame.count > 8,
+ frame[8] == 43 || frame[8] == 51,
+ !rawCaptureInFlight, !UserDefaults.standard.bool(forKey: "enableRawCapture"),
+ now.timeIntervalSince(rawCaptureStoppedAt) >= 3,
+ now.timeIntervalSince(unexpectedImuStopAt) >= 30 else { return }
+ unexpectedImuStopAt = now
+ send(.stopRawData, payload: [0x01], writeType: .withResponse)
+ send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse)
+ log("Raw IMU fail-safe: unexpected realtime packet type \(frame[8]) while capture was off; stop requested")
+ }
+
+ public func groundTruthHistoryCSV(from: Int, to: Int) async -> Data {
+ await collector?.historySensorsCSV(from: from, to: to)
+ ?? Data("stream,unix_s,v1,v2,v3,v4\n".utf8)
+ }
+
/// Send a command to the WHOOP strap.
/// - Parameters:
/// - command: The command to send.
@@ -1857,6 +2256,10 @@ public final class BLEManager: NSObject, ObservableObject {
|| (DeviceConfigReadProbe.isReadOnlyOpcode(command.rawValue) && deviceConfigReport != nil)
|| command == .sendHistoricalData || command == .historicalDataResult
|| command == .setClock || command == .getClock
+ // Bounded Raw Data Collector only. These writes remain impossible unless the explicit
+ // user-started capture window is in flight; normal sync never enables this gate.
+ || (rawCaptureInFlight && (command == .startRawData
+ || command == .stopRawData || command == .toggleIMUMode))
// SET_CONFIG / SET_FF_VALUE (120), ENABLE direction — the R22 deep-stream unlock. Allowed
// only while the deep-data experiment is opted in, and only for a KEY and a VALUE the gate
// recognises: one of the sixteen R22 flags carrying that key's own enable value. The clause
@@ -2340,8 +2743,27 @@ public final class BLEManager: NSObject, ObservableObject {
// STALLED on a persist failure" — the latter (usually a restore without a restart) is otherwise
// invisible in a report that just shows "0 synced".
let du = UserDefaults.standard
- if (backfiller?.sessionRowsPersisted ?? 0) > 0 { du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteOkAt") }
- if backfiller?.persistStalled == true { du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteStalledAt") }
+ // Per strap, not per install. The global keys reported one strap's write health on another's
+ // screen — see LastSyncAttribution. This site has the peripheral, so it is the one place that
+ // can attribute them honestly (same argument as #1634's firmware write).
+ let whoOk = LastSyncAttribution.writeHealthPrefKey(peripheralId: peripheral?.identifier.uuidString,
+ kind: "lastWriteOkAt")
+ let whoStalled = LastSyncAttribution.writeHealthPrefKey(peripheralId: peripheral?.identifier.uuidString,
+ kind: "lastWriteStalledAt")
+ // The global keys are STILL written, deliberately, exactly as `noop.lastFirmware` still is
+ // (FrameRouter). DebugDataDiagnostics.strapStateLines is sync and prefs-only by contract, so it
+ // cannot resolve per device — and dropping the global write would not make that line
+ // per-device, it would freeze it at whatever it held before the upgrade and then read "no rows
+ // ever persisted" forever, for everyone. A stale global is the smaller lie than a dead one, and
+ // every reader that CAN attribute prefers the per-device key.
+ if (backfiller?.sessionRowsPersisted ?? 0) > 0 {
+ du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteOkAt")
+ if let k = whoOk { du.set(Date().timeIntervalSince1970, forKey: k) }
+ }
+ if backfiller?.persistStalled == true {
+ du.set(Date().timeIntervalSince1970, forKey: "sync.lastWriteStalledAt")
+ if let k = whoStalled { du.set(Date().timeIntervalSince1970, forKey: k) }
+ }
if unarchived > 0 {
state.lastSyncError = "Synced, but \(archived + unarchived) record(s) couldn't be decoded (unrecognised strap firmware layout), and the on-device archive is full - the \(unarchived) newest weren't preserved. Please share a strap log so the layout can be mapped."
} else if archived > 0 {
@@ -2356,8 +2778,39 @@ public final class BLEManager: NSObject, ObservableObject {
? "console-only across \(state.consoleChunksThisSession) chunks"
: "metadata-only, 0 sensor rows persisted"
log("Backfill: completed but the strap banked no sensor history (\(detail)); consecutive empty syncs = \(emptySyncTracker.consecutiveEmptySyncs).")
+ // #1683: say HOW OLD the strap's newest stored record is. Without it the line above reads
+ // identically for a strap that is caught up and one that stopped banking three weeks ago -
+ // GET_DATA_RANGE already told us the difference and we simply never said so, which is why
+ // #1541 stayed open and unactionable. Rare-event evidence, so always-on.
+ let wallNowUnix = Int(Date().timeIntervalSince1970)
+ // Read the field ONCE: it feeds both the log line and the banner below, and they must not
+ // disagree about whether the strap is stale.
+ // WHOOP4 only, explicitly, on BOTH platforms. Redundant here today - `strapNewestTs` is
+ // only assigned when `feedsSync` (#695), which is WHOOP4 - but stated so a later widening
+ // of feedsSync cannot make this fire where the Android twin does not. A 5/MG that cannot
+ // offload has no business being told it stopped saving to flash.
+ let staleNewest: Int? = selectedModel.deviceFamily == .whoop4
+ ? strapNewestTs.flatMap {
+ Backfiller.isStaleNewestRecord(newestUnix: $0, wallNowUnix: wallNowUnix) ? $0 : nil
+ }
+ : nil
+ if let newest = staleNewest {
+ log(Backfiller.staleRecordLine(newestUnix: newest, wallNowUnix: wallNowUnix))
+ }
+ // #1683: when the strap's own newest record dates the silence, SAY it. The generic copy
+ // below omits that and promises a recovery the charge advice has already been retried for
+ // every session; the dated version is the one a stuck user can act on.
+ //
+ // #1754: the generic "clock lost sync" copy is only correct when the strap reported
+ // trim=0xFFFFFFFF (no valid flash cursor). A strap with a valid, advancing flash cursor
+ // that banks no sensor records has a different problem — the sensor front-end or power,
+ // not the clock — and telling the user to charge it sends them away from the real cause.
+ // The two states are distinguished by `backfiller.sawNoFlashCursor`.
state.lastSyncError = sustainedEmpty
- ? "Synced, but your strap had no stored history to hand over - only its diagnostic output. This usually means its clock has lost sync, so it isn't saving data to flash. Fully charge it to 100%, then reconnect, and it should start banking again."
+ ? (staleNewest.map { Backfiller.staleRecordBanner(newestUnix: $0, wallNowUnix: wallNowUnix) }
+ ?? (backfiller?.sawNoFlashCursor == true
+ ? Backfiller.noFlashCursorBanner
+ : Backfiller.noSensorRecordsBanner))
: nil
} else if let futureBanner = futureClockBanner {
// #324/#928: the strap banked records but its newest is dated implausibly in the FUTURE
@@ -2375,6 +2828,16 @@ public final class BLEManager: NSObject, ObservableObject {
whoop5EmptyOffload.reset()
state.historySyncExperimental = false
}
+ // Stamped against the strap that actually completed this offload, never globally: the single
+ // key reported one strap's sync on another's screen, and it read as reassuring rather than
+ // wrong — see LastSyncAttribution. Kotlin twin: NoopPrefs.setLastSyncAtFor.
+ if let key = LastSyncAttribution.prefKey(peripheralId: peripheral?.identifier.uuidString) {
+ UserDefaults.standard.set(state.lastSyncedAt, forKey: key)
+ }
+ // Global kept for the same reason as the write-health pair above: the prefs-only debug header
+ // is its only remaining reader and cannot resolve per device, so dropping this write would
+ // freeze that line rather than fix it. It is also what `seedLastSyncFromActiveStrap`'s
+ // single-strap fallback reads across the upgrade.
UserDefaults.standard.set(state.lastSyncedAt, forKey: "lastSyncedAt")
// NOTE: the auto-continue streak is NOT reset here. A HISTORY_COMPLETE is no longer assumed to
// mean "caught up" (#25): a strap whose firmware segments a deep offload into many small
@@ -2465,6 +2928,27 @@ public final class BLEManager: NSObject, ObservableObject {
Task { @MainActor in
let frontier = await collector?.latestHRSampleTs() ?? nil
let wallNow = Int(Date().timeIntervalSince1970) // #928: real wall clock, at decision time
+ // #1164: publish whether the strap has banked records newer than our local frontier, so the
+ // Today Rest card can show "Pending sync" instead of a provisional number. Same behind check
+ // the auto-continue predicate uses (5-min gap), computed here because this is the one path
+ // that already reads both values. Caught-up (or unknown) → false, never a stale "pending".
+ // #1164 + #928/#1012 + #1144: the bare gap is not enough. Both traps that
+ // `shouldAutoContinue` guards against latch this flag TRUE forever, which would pin Rest to
+ // "Pending sync" and never show a score — strictly worse than the provisional number this
+ // exists to hide.
+ // - a strap whose clock is set in the FUTURE reads ahead of ANY frontier, so the gap never
+ // closes (there is a user-facing banner for exactly that state);
+ // - a PHANTOM gap (a timestamp the strap will not actually offload, a console-only tail, a
+ // dup re-offload) advertises newer data while banking no new rows, so the frontier cannot
+ // advance and the gap stays open. `persistedSensorRows` is the same evidence #1144 added to
+ // the auto-continue predicate for this exact latch; a caught-up strap is already false via
+ // the gap, so gating on it only bites the phantom case.
+ if let n = newest, let f = frontier {
+ state.historyPendingSync =
+ !BackfillContinuation.isFutureDatedNewest(n, wallNowUnix: wallNow)
+ && persistedSensorRows
+ && (n - f) > BackfillContinuation.defaultBehindGapSeconds
+ }
let stillConnected = state.connected && state.bonded
guard BackfillContinuation.shouldAutoContinue(
stillConnected: stillConnected,
@@ -3726,6 +4210,20 @@ public final class BLEManager: NSObject, ObservableObject {
log("ECG probe: ignored — not connected")
return false
}
+ // #1635: the MG check above used to block this incidentally on a suppressed strap, because the
+ // variant could only resolve from a POST-BOND DIS read and so stayed `.unknown` forever. Now that
+ // DIS is read on an unbonded link too, a suppressed MG can attest itself — and this gate would
+ // have let the probe write puffin frames to fd4b0002, the very characteristic whose write tears
+ // the link down. That would spend the stable link the suppression exists to buy, on commands the
+ // strap cannot answer unbonded anyway.
+ //
+ // The ECG GATE-WRITE path already required this and says why (#269: a config write over the
+ // live-HR-only link silently fails). The probe needs it for the same reason, and now also to stay
+ // out of the way of #1635.
+ guard state.encryptedBond else {
+ log("ECG probe: ignored — needs the full encrypted bond, not the live-HR-only link (#1635/#269)")
+ return false
+ }
return true
}
@@ -3834,10 +4332,19 @@ public final class BLEManager: NSObject, ObservableObject {
///
/// Called only while a run is armed, and only for non-offload live frames.
private func noteEcgProbeFrame(_ frame: [UInt8]) {
- // CRC GATE FIRST (safety contract §2: "New inbound paths must do the same"). The verdict this
- // probe produces is its entire output, and a single flipped bit at byte 12 would turn a healthy
- // SUCCESS into "DATA REQUEST REFUSED" — the strongest claim the report can make. So no byte of
- // an unverified frame is read here, on either branch.
+ // INTEGRITY GATE FIRST (safety contract §2: "New inbound paths must do the same"). The verdict
+ // this probe produces is its entire output, and a single flipped bit at byte 12 would turn a
+ // healthy SUCCESS into "DATA REQUEST REFUSED" — the strongest claim the report can make. So no
+ // byte of an unverified frame is read here, on either branch.
+ //
+ // This is the app's only DIRECT call into the verifier rather than into a parser, so the
+ // widened verdict lands here first, and it CHANGES WHAT COUNTS AS EVIDENCE. The gate used to
+ // mean "the payload CRC32 is not demonstrably wrong"; it now also requires the CRC16 header
+ // checksum and the exact declared length. A frame with a damaged envelope therefore no longer
+ // settles a step's outcome AND no longer counts as a candidate ECG data packet — so it can
+ // neither produce a refusal the strap never uttered, nor pad the packet count that the report's
+ // silence-based signals are weighed against. Both directions are intended: silence attributed
+ // to nothing is a weaker claim than a verdict attributed to a corrupted frame.
guard verifyFrame(frame, family: .whoop5).ok else { return }
// Both COMMAND_RESPONSE spellings: 0x24 (36, what the #592/#690 handlers key on) and the puffin
// alias 38, which `canonicalTypeName` folds onto the same name. Accepting both means a strap that
@@ -3987,8 +4494,18 @@ public final class BLEManager: NSObject, ObservableObject {
}
private func keepAliveFire() {
- guard state.connected, didBond else { return }
- enableLiveNotifications(reason: "keepalive")
+ // #1635: a SUPPRESSED 5/MG never bonds, so a bare `didBond` gate switches this whole timer off for
+ // exactly the link that now stays up for hours - taking the liveness watchdog below with it and
+ // leaving a stalled stream with nothing to bounce it. That would quietly break the "stable live HR"
+ // the suppression exists to deliver. Android gates its twin on `bonded` (the live-HR shortcut, #69)
+ // and so reaches the same watchdog.
+ //
+ // Deliberately NARROWER than Android's gate: admitted only for a streaming 5/MG, and only as far as
+ // the watchdog. Everything past it needs the encrypted bond, so a second `didBond` guard closes the
+ // door again rather than letting an unbonded link issue puffin work that cannot land.
+ guard keepAliveMayRun(connected: state.connected, didBond: didBond,
+ bonded: state.bonded, family: selectedModel.deviceFamily) else { return }
+ if didBond { enableLiveNotifications(reason: "keepalive") }
// Liveness watchdog: if NOTHING has arrived for a while, the stream/link stalled.
// Bounce the connection — the auto-rescan on disconnect re-bonds and resumes streaming.
// #580 / #1414: the standard 0x2A37 HR profile keeps the link genuinely alive, but its packets can
@@ -4005,6 +4522,26 @@ public final class BLEManager: NSObject, ObservableObject {
if let p = peripheral { central.cancelPeripheralConnection(p) }
return
}
+ // The watchdog above is the whole of the keep-alive an unbonded 5/MG can use. Everything below
+ // sends puffin-framed work that needs the encrypted bond.
+ //
+ // #1953: an unbonded 5/MG (the #1635 suppressed-hello case) still gets a periodic 0x2A19 battery
+ // read on Android — `keepAliveFire` there gates on `bonded` (the live-HR shortcut), not on the
+ // encrypted bond, and polls battery on the same ~60 s cadence regardless. iOS gates the tick on
+ // `keepAliveMayRun` (which admits `bonded && .whoop5`) but the battery read lives inside
+ // `enableLiveNotifications`, which is `didBond`-gated — so the tick fires and the read does not,
+ // and an unbonded 5/MG's battery % refreshes only when the UI asks. Poll 0x2A19 here on the SAME
+ // throttle `enableLiveNotifications` uses, so the two platforms read at the same cadence without
+ // doubling the rate on a bonded strap (this path only runs when `!didBond`).
+ if !didBond, selectedModel.deviceFamily == .whoop5,
+ let p = peripheral, let b = batteryCharacteristic, b.properties.contains(.read),
+ BLEManager.shouldPollWhoop5Battery(lastReadAt: lastBatteryReadAt,
+ charging: state.charging == true) {
+ p.readValue(for: b)
+ lastBatteryReadAt = Date()
+ log("Reading 5/MG battery (unbonded keep-alive) (#1953)")
+ }
+ guard didBond else { return }
guard !backfilling else { return } // never poke the strap mid-offload
// #927: continuous capture can be overnight-only, which makes the want TIME-dependent; nothing
// else re-evaluates it while the app just sits connected, so the keep-alive tick re-derives it.
@@ -4036,7 +4573,33 @@ public final class BLEManager: NSObject, ObservableObject {
} // re-arm so it can't lapse
keepAliveTick += 1
// #battery: ~60 s normally, ~30 s while charging (see `batteryPollDue`).
- if BLEManager.batteryPollDue(tick: keepAliveTick, charging: state.charging == true) {
+ //
+ // WHOOP 4.0 ONLY (#1948). Both commands this block used to send are refused on a 5/MG before they
+ // leave the app: the send allowlist has no clause for `.getBatteryLevel` at all, and admits
+ // `.getBatteryPackInfo` only while a user-initiated probe is in flight. Two dead sends and two
+ // skip lines per tick, under a comment claiming the pack "rides the SAME cadence as the strap's
+ // own gauge". It never rode anything.
+ //
+ // Removing them cannot change what a 5/MG receives, and that is the whole argument: the allowlist
+ // refused both before they reached the peripheral, so no reading ever depended on either. Do not
+ // reach for a subtler one. An earlier draft here argued that the `didBond` letting this tick run
+ // had already driven the 0x2A19 read a few lines above, which is wrong twice over —
+ // `keepAliveMayRun` also admits a `bonded && .whoop5` tick with `didBond` false, and
+ // `enableLiveNotifications` is separately gated on `didBond`, so on a #1635 strap the tick fires
+ // and that read does not.
+ //
+ // #1953 closed that divergence: the unbonded 5/MG battery read now fires from the keep-alive tick
+ // itself (above the `guard didBond` line), on the SAME throttle `enableLiveNotifications` uses, so
+ // an unbonded 5/MG gets a periodic battery read on iOS at the same ~60 s cadence Android does. The
+ // pack's charge comes from the pushed pack-info event (109) on both, that flag's only writer since
+ // #1945.
+ //
+ // This leaves opcode 151 with NO sender on iOS, which is the state `FrameRouter` already records
+ // for its own decoder ("nothing sent the command, so the decoder had no caller"). Android keeps a
+ // user-initiated probe for it; there is no iOS twin of that, so asking a 5/MG whether it answers
+ // 151 at all is an Android-side question today.
+ if selectedModel.deviceFamily != .whoop5,
+ BLEManager.batteryPollDue(tick: keepAliveTick, charging: state.charging == true) {
send(.getBatteryLevel, payload: [])
}
}
@@ -4151,9 +4714,11 @@ public final class BLEManager: NSObject, ObservableObject {
batteryCharacteristic = nil
disSerialCharacteristic = nil
disHwRevCharacteristic = nil
+ disExtraCharacteristics = []
disRead = false
disSerial = nil
disHwRev = nil
+ disFirmware = nil
whoop5NotifyCharacteristics.removeAll()
}
@@ -4162,11 +4727,16 @@ public final class BLEManager: NSObject, ObservableObject {
/// `allowFallback` is true, schedule a one-shot rotation to the other WHOOP family after
/// `scanFallbackDelaySeconds` of no discovery — recovers reconnect when the persisted preference is
/// stale after an update/restore. Discovery/connect cancels the pending rotation. (PR#195)
+ /// No gate here, deliberately: the ONLY callers are `connectCore` — reached from the user's Connect or
+ /// from the already-gated `connectFromSystem` — and this method's own family-rotation timer, which
+ /// cannot start a scan that one of those did not. Gating here as well would block the user's Connect.
private func startScan(for model: WhoopModel, allowFallback: Bool) {
+ advertisementLogged = false
cancelScanFallback()
selectedModel = model
reassembler = Reassembler(family: model.deviceFamily)
router.family = model.deviceFamily
+ router.deviceId = deviceId // #1706: attribute this connection's alarm readback
configureCollectorFamily()
central.stopScan()
log("Scanning for \(model.displayName)…")
@@ -4206,6 +4776,7 @@ public final class BLEManager: NSObject, ObservableObject {
/// #730 pending-connect probe. Both restore entry points funnel through here so the two paths can be
/// told apart in a strap log.
private func connectRestored(_ p: CBPeripheral, reason: String) {
+ guard whoopConnectAllowed("restored/\(reason)") else { return }
log("Connecting to restored peripheral (\(reason)) — peripheral state=\(peripheralStateName(p.state))")
central.connect(p, options: nil)
pendingConnectProbe?.cancel()
@@ -4278,9 +4849,80 @@ public final class BLEManager: NSObject, ObservableObject {
disRead = true
p.readValue(for: serialChar)
if let c = disHwRevCharacteristic, c.properties.contains(.read) { p.readValue(for: c) }
+ readDisExtras(p)
+ }
+ }
+
+ /// Read the DIS identity extras — firmware above all, which an unbonded 5/MG has no other source for.
+ ///
+ /// DIVERGENCE FROM ANDROID (deliberate, platform): the Kotlin twin CHAINS these one read per callback,
+ /// because its GATT queue runs a single operation at a time and a second read issued early is simply
+ /// dropped. CoreBluetooth queues reads itself, so chaining here would add a state machine to serialise
+ /// something already serialised. Same characteristics, same data, no chain.
+ ///
+ /// A strap need not implement all of DIS: a missing or unreadable characteristic is skipped, because
+ /// "not implemented" is per-characteristic and the ones it DOES publish should still be read.
+ private func readDisExtras(_ p: CBPeripheral) {
+ for c in disExtraCharacteristics where c.properties.contains(.read) {
+ p.readValue(for: c)
}
}
+ /// The UNBONDED DIS attempt (#1635 follow-up). Deliberately separate from the post-bond identity read,
+ /// which runs only inside the handshake and therefore never runs at all on a strap that will not bond.
+ ///
+ /// Delayed for the same reason the Kotlin twin delays it: this is only safe in the settled state the
+ /// suppression leaves behind — no handshake outstanding, nothing else in flight, the link holding.
+ private func scheduleUnbondedDisRead() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + BLEManager.unbondedDisReadDelay) { [weak self] in
+ self?.readDisIdentityUnbonded()
+ }
+ }
+
+ private func readDisIdentityUnbonded() {
+ // `peripheral` is only cleared by forgetDevice, so it OUTLIVES a disconnect - and this read is
+ // scheduled with a delay, on a strap whose entire failure mode is dropping the link. Without the
+ // connected check the reads would be issued into a dead link and the line below would claim an
+ // attempt that never happened, which is precisely what a diagnostic may not do.
+ guard state.connected, let p = peripheral else { return }
+ let refused = disRefusedPrefKey(p.identifier.uuidString)
+ .map { UserDefaults.standard.bool(forKey: $0) } ?? false
+ guard shouldReadDisUnbonded(isWhoop5: selectedModel.deviceFamily == .whoop5,
+ bonded: didBond,
+ alreadyReadThisLink: disRead,
+ previouslyRefused: refused) else { return }
+ guard let serialChar = disSerialCharacteristic, serialChar.properties.contains(.read) else {
+ log("DIS: serial characteristic unavailable — hardware variant stays unknown")
+ return
+ }
+ log("DIS: trying the identity read on an UNbonded link — unproven, and a refusal is itself the answer to whether DIS needs an encrypted bond (#490)")
+ disRead = true
+ p.readValue(for: serialChar)
+ if let c = disHwRevCharacteristic, c.properties.contains(.read) { p.readValue(for: c) }
+ readDisExtras(p)
+ }
+
+ /// A DIS read that came back with a failure status.
+ ///
+ /// Scoped to the DIS characteristics: they are the reads whose refusal is a FINDING (it settles #490
+ /// and means the firmware cannot be had without a bond). The refusal is latched per device, so a strap
+ /// that declines says so once instead of on every reconnect forever.
+ private func noteDisReadFailure(_ uuid: CBUUID, _ error: Error) {
+ guard BLEManager.disChars.contains(uuid) else { return }
+ log(disReadFailureLine(uuid: uuid.uuidString, status: error.localizedDescription))
+ // Report it, but latch it ONLY for an unbonded attempt. The latch is persisted and permanent, so a
+ // transient failure on a BONDED link would disable the unbonded read for this strap for good and
+ // blame the strap for something that was never its policy. This is the analogue of the Kotlin
+ // twin's explicit-bond guard: same reasoning, different thing to exclude, because the bonded read
+ // is the only way a DIS failure reaches here without being the finding we are looking for.
+ guard !didBond else {
+ log("DIS: not latching that refusal — the link is bonded, so the failure is not attributable to the strap's unbonded-read policy")
+ return
+ }
+ guard let key = disRefusedPrefKey(peripheral?.identifier.uuidString) else { return }
+ UserDefaults.standard.set(true, forKey: key)
+ }
+
/// Resolve + log the 5/MG hardware variant once a DIS string lands (#520). Both characteristics
/// arrive as SEPARATE async callbacks, so this runs on each and re-resolves — the contradiction rule
/// in `Whoop5Variant` needs both before it can disagree. Diagnostic only: nothing gates on it yet.
@@ -4296,6 +4938,69 @@ public final class BLEManager: NSObject, ObservableObject {
// existing consumer — nothing about framing or decode reads this.
state.whoop5Variant = variant.label
reconcileModelFromAttestation(variant)
+ adoptWhoopSerialIdentity()
+ }
+
+ /// The serial this pairing may adopt: the 5/MG's DIS read, or a 4.0's twice-seen hello serial.
+ private var adoptableSerial: String? { disSerial ?? harvardSerialConfirmed }
+
+ /// #1193: a 4.0 hello serial arrived. Adopt only on the SECOND sighting of the same value — see
+ /// `harvardSerialCandidate` for why a single sighting is not enough to act on destructively.
+ private func noteHarvardSerial(_ serial: String) {
+ // Do NOT advance the gate before the store exists. The gate confirms EXACTLY ONCE, so a
+ // confirmation raised while `adoptWhoopSerialIdentity` would bail is not deferred, it is lost for
+ // the lifetime of the process — every later sighting returns nil because the value is already
+ // confirmed. The 5/MG path survives the same hazard only because it re-offers its DIS serial on
+ // every connect; a once-only gate has no such second chance, so it must not consume a sighting it
+ // cannot act on. Kotlin twin guards its own missing dependency the same way.
+ guard registryStore != nil else { return }
+ guard let confirmed = harvardSerialGate.offer(serial) else { return }
+ harvardSerialConfirmed = confirmed
+ adoptWhoopSerialIdentity()
+ }
+
+ /// #1303: the 5/MG DIS read hands us the strap's OWN serial, so re-point this pairing from its
+ /// transient CoreBluetooth-UUID id onto a stable `whoop-` id — through the SAME generic
+ /// migration the ring already uses (`adoptSerialIdentity`, #771), so a re-pair or factory reset stops
+ /// forking one physical strap into a second row and orphaning its history (#1193).
+ ///
+ /// A WHOOP 4.0 reaches this by a different road. It exposes no DIS serial (the read above is gated
+ /// `!= .whoop4`), so its serial comes from the `GET_HELLO_HARVARD` response instead — see
+ /// `Whoop4HelloSerial` for the offset and `noteHarvardSerial` for why a 4.0 waits for a second
+ /// sighting before adopting where a 5/MG adopts on the first read (#1193).
+ ///
+ /// Deferred to the next main-loop turn, mirroring `adoptOuraSerial`: adoption re-points the ACTIVE
+ /// device, and the observers that react tear down and rebuild the very connection this callback is
+ /// running inside, so the current BLE callback must return first. Idempotent — after the first
+ /// adoption the id already equals the serial id, so every later reconnect costs one string compare and
+ /// touches no database. A serial `WhoopSerialIdentity` refuses (blank, truncated, non-serial prose)
+ /// leaves the strap on its existing id: adopting onto a junk id would migrate every device-scoped row
+ /// onto a garbage key, which is worse than not adopting.
+ private func adoptWhoopSerialIdentity() {
+ guard let rs = registryStore,
+ let serialId = WhoopSerialIdentity.adoptedId(serial: adoptableSerial),
+ let active = try? rs.all().first(where: { $0.status == .active }),
+ WhoopSerialIdentity.mayAdopt(currentId: active.id),
+ active.id != serialId
+ else { return }
+ let currentId = active.id
+ Task { @MainActor [weak self] in
+ guard let self, let rs = self.registryStore,
+ (try? rs.all().first(where: { $0.status == .active }))?.id == currentId
+ else { return }
+ guard (try? rs.adoptSerialIdentity(from: currentId, to: serialId)) == true else { return }
+ try? rs.setActive(serialId)
+ // The rows have MOVED to the serial id and the old registry row is gone, so the live persist
+ // paths must follow in the same turn: `deviceId`, the Collector and the Backfiller all stamp
+ // rows at write time, and leaving them on the now-deleted id would write new samples into a
+ // second, orphaned history — the same split this phase exists to prevent, just on the
+ // provisional path instead of the legacy one. Kotlin reaches the identical call through
+ // `SourceCoordinator.pointWhoop`, which re-points any non-legacy id.
+ self.setActiveDeviceId(serialId)
+ // Prefix only. `serialId` embeds the full serial, which must never reach a shareable log.
+ self.log("Adopted stable serial identity (serialPrefix=\(WhoopSerialIdentity.logSafe(serial: self.adoptableSerial))) - history re-pointed off the transient pairing id (#1303)")
+ self.onSerialIdentityAdopted?(serialId)
+ }
}
/// The strap's own DIS attestation is ground truth (a WHOOP 4.0 never attests a 5AM/5AG serial). When
@@ -4437,6 +5142,9 @@ public final class BLEManager: NSObject, ObservableObject {
private func recordAlarmArm(sentEpoch: Int) {
let d = UserDefaults.standard
d.set(sentEpoch, forKey: "alarm.lastArmSentEpoch")
+ // #1706: WHICH strap this arm went to. Without it the export, and the reject streak, compare
+ // this epoch against a readback that on a multi-strap install may be a different device.
+ d.set(deviceId, forKey: "alarm.lastArmDeviceId")
d.set(Date().timeIntervalSince1970, forKey: "alarm.lastArmAt")
d.set(commandChannelReady, forKey: "alarm.lastArmConnected") // #613: true only if the arm actually went out
// #34: the strap-clock skew (its own RTC minus wall, seconds) AT THE MOMENT we armed. A wrong RTC
@@ -4624,7 +5332,8 @@ public final class BLEManager: NSObject, ObservableObject {
// change so a steady resting HR doesn't re-render the whole Live console every second.
if m.hr >= 30 && m.hr <= 220, state.heartRate != m.hr { state.heartRate = m.hr }
// Record it continuously — independent of the realtime stream or the open screen.
- collector?.ingestStandardHR(hr: m.hr, rr: m.rr, at: Int(Date().timeIntervalSince1970))
+ collector?.ingestStandardHR(hr: m.hr, rr: m.rr, contact: m.contact,
+ at: Int(Date().timeIntervalSince1970))
}
}
@@ -4768,6 +5477,36 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name ?? "unknown"
+ // The raw name goes to the DEVICE LIST (the user's own screen, where they need to recognise
+ // their strap); only the log gets the model-only form. See LiveState.logSafeDeviceName.
+ let safeName = LiveState.logSafeDeviceName(name)
+ // #1635: what the strap ADVERTISED, once per scan. The open question there is whether a
+ // refusing strap was in pairing mode, and a strap that accepts pairing should advertise
+ // differently — but nothing recorded the advertisement, so no log could say. Structure only,
+ // never payload: see `ScanAdvertisementSummary`. Test Centre gated. CoreBluetooth exposes no AD
+ // flags byte, so that field reads `none` here and carries on the Kotlin side.
+ if !advertisementLogged, TestCentre.active(.connection) {
+ advertisementLogged = true
+ let svc = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?
+ .map { $0.uuidString.lowercased() } ?? []
+ var svcLens: [String: Int] = [:]
+ if let sd = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] {
+ for (k, v) in sd { svcLens[k.uuidString.lowercased()] = v.count }
+ }
+ var mfgLens: [Int: Int] = [:]
+ if let md = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, md.count >= 2 {
+ mfgLens[Int(md[0]) | (Int(md[1]) << 8)] = md.count - 2
+ }
+ state.append(log: ScanAdvertisementSummary.line(
+ flags: nil,
+ serviceUuids: svc,
+ serviceDataLengths: svcLens,
+ manufacturerDataLengths: mfgLens,
+ txPower: (advertisementData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber)?.intValue,
+ localNameLength: ScanAdvertisementSummary.localNameLength(
+ advertisementData[CBAdvertisementDataLocalNameKey] as? String),
+ connectable: (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true))
+ }
// #716: the seeded "my-whoop" device has model "WHOOP" (no generation). Once a live scan
// confirms which service family the strap advertises, stamp the correct model so
// forRegistryModel returns the right DeviceFamily (fixes skin-temp ADC scale + display).
@@ -4786,10 +5525,10 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
)
if !scanDecision.shouldConnect {
if let family = scanDecision.unsupportedFamily {
- log("Discovered \(name) (rssi \(RSSI)) — \(family.diagnosticUnsupportedMessage)")
+ log("Discovered \(safeName) (rssi \(RSSI)) — \(family.diagnosticUnsupportedMessage)")
return
}
- log("Discovered \(name) (rssi \(RSSI)) without \(selectedModel.displayName) service — ignoring")
+ log("Discovered \(safeName) (rssi \(RSSI)) without \(selectedModel.displayName) service — ignoring")
return
}
// Multi-WHOOP present-scan (Add-a-WHOOP wizard): collect the strap, do NOT auto-connect, and
@@ -4810,12 +5549,14 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
// WHOOP default) this guard is skipped and the original "connect to the first discovered" path
// below is byte-for-byte unchanged.
if let preferred = preferredPeripheralUUID, peripheral.identifier != preferred {
- log("Discovered \(name) (\(peripheral.identifier)) — not the preferred strap; ignoring")
+ log("Discovered \(safeName) (\(peripheral.identifier)) — not the preferred strap; ignoring")
return
}
+ // No gate here for the same reason as `startScan`: reaching a discovery means a scan is running,
+ // and a scan only runs because the user asked or because the gated system entry allowed it.
cancelScanFallback()
persistSelectedModel(selectedModel)
- log("Discovered \(name) (rssi \(RSSI)) — connecting")
+ log("Discovered \(safeName) (rssi \(RSSI)) — connecting")
central.stopScan()
preparePeripheral(peripheral)
central.connect(peripheral, options: nil)
@@ -4825,9 +5566,21 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
cancelScanFallback()
cancelPendingConnectProbe() // #730: the connect resolved; no pending-connect log needed
failedConnectAttempts = 0 // a successful connect clears the reconnect backoff (#414)
+ // #1809: this link's inbound tally starts empty; the epitaph on disconnect reports exactly what
+ // arrived between here and there.
+ inboundFrames = 0; inboundBytes = 0; cmdChannelFrames = 0
+ // #1635: same guarantee for the banked tally. Clearing only on teardown would be enough if every
+ // link ended in one, and a link that begins without a preceding clean teardown would otherwise
+ // open holding the previous link's rows — reporting them as banked on a link that never saw them.
+ liveHr = 0; liveRr = 0; offloadHr = 0; offloadRr = 0
+ offloadGravity = 0; offloadResp = 0; offloadSkinTemp = 0; offloadSpo2 = 0; offloadChunks = 0
+ linkUpSince = DispatchTime.now()
standingConnectAt = nil // #1413: a live link means no standing connect is outstanding
restoredPeripheral = nil
preparePeripheral(peripheral)
+ // #1881: BEFORE anything persists. The Collector and Backfiller read `deviceId` at flush and at
+ // finishChunk, so re-pointing here is what keeps this link's rows off another device's id.
+ adoptSourceIdentity(for: peripheral)
// Clear the per-connection bond BEFORE publishing the connected uuid below. SourceCoordinator's #52
// re-adoption gate keys off `encryptedBond` at the instant `connectedPeripheralUUID` is observed —
// an ordinary `didConnect` publish must always read false (only the deliberate post-bond #52
@@ -4890,6 +5643,53 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
return "code?"
}
+ /// Feed one 5/MG bond refusal into the #747 give-up and act on the trip (#1635).
+ ///
+ /// Both causes arrive here, and they want OPPOSITE treatment — see `giveUpSuppressesHello`. An auth
+ /// refusal means the strap actively declined, so pausing auto-reconnect is right. An unanswered
+ /// handshake means the write vanished while the link streamed live HR the whole time, so pausing
+ /// would throw away a working strap to punish a handshake nobody is waiting on.
+ ///
+ /// Shared by both call sites so the split cannot drift: the auth path (a write error) and the
+ /// unanswered path (a disconnect with a hello still outstanding) reach the same decision.
+ private func recordWhoop5BondRefusal(authRefusal: Bool, peripheralUUID: String) {
+ // #1635: the two causes want different patience, for the same reason they want different
+ // outcomes - see `giveUpThresholdFor`. Keyed on the same authRefusal as the branch below, so the
+ // threshold and the treatment can never disagree about which kind of refusal this is.
+ guard bondGiveUp.recordRefusal(
+ threshold: giveUpThresholdFor(authRefusal: authRefusal, pauseThreshold: bondGiveUp.giveUpThreshold)
+ ) else { return }
+ let opaque = BondRefusalGiveUp.opaqueId(fromLocalUUID: peripheralUUID)
+ let suppress = giveUpSuppressesHello(authRefusal: authRefusal)
+ if suppress {
+ HelloSuppressionStore.setSuppressed(peripheralUUID, true)
+ log(BondRefusalGiveUp.helloSuppressedEpitaph(refusals: bondGiveUp.refusals, opaqueId: opaque))
+ } else {
+ autoReconnectPausedForBondLoop = true
+ bondLoopPausedAt = Date() // starts the #78 hole-4 salvage-probe floor
+ // #1539: park the connect in the same breath as the pause, so this can end while backgrounded.
+ standingConnectWhilePausedIfDue(justTripped: true)
+ log(BondRefusalGiveUp.epitaphLine(refusals: bondGiveUp.refusals, opaqueId: opaque))
+ }
+ // Each branch gets the hint that matches what it actually DID. The paused hints say "auto-reconnect
+ // is paused"; on the suppression branch nothing is paused, so saying so would be the same
+ // confidently-wrong diagnostic this issue has produced twice already (#1635). The third arm is
+ // unreachable while `giveUpSuppressesHello` is `!authRefusal`, and is kept because the Kotlin twin
+ // keeps it: if that rule ever changes, both platforms must change behaviour together.
+ if suppress {
+ state.pairingHint = BondRefusalGiveUp.helloSuppressedHint()
+ } else if authRefusal {
+ state.pairingHint = BondRefusalGiveUp.pausedHint()
+ } else {
+ state.pairingHint = BondRefusalGiveUp.pausedHintHandshakeUnanswered()
+ }
+ if TestCentre.active(.connection) {
+ state.append(log: "bond gaveUp refusals=\(bondGiveUp.refusals) id=\(opaque) " +
+ (suppress ? "(hello suppressed, staying connected)" : "(auto-reconnect paused)"),
+ domain: .connection)
+ }
+ }
+
public func centralManager(_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?) {
@@ -4915,6 +5715,50 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
// arm timestamp. A drop that is unintentional, error-bearing, and lands shortly after we armed
// the R10/R11 burst is the marginal-radio tell. Feed the detector; if it trips, the NEXT connect
// skips the heavy arm (the flag is intentionally NOT reset on disconnect so it survives rescan).
+ // #1809: the epitaph goes out BEFORE the resets below, for the same reason the two detectors read
+ // their state here - `realtimeArmedAt` is about to be cleared. `ended` carries the CBError CASE, not
+ // just the OS sentence: the #617 branch already computes that code and the strap log never saw it.
+ let endedReason: String
+ if intentionalDisconnect {
+ endedReason = "intentional"
+ } else if let cb = error as? CBError {
+ endedReason = "CBError.\(cb.code)(\(cb.code.rawValue))"
+ } else if let error {
+ endedReason = "\(error)"
+ } else {
+ endedReason = "no error reported"
+ }
+ // Only for a link we actually held. Emitting without one would report "the strap sent NOTHING on
+ // this link" using the PREVIOUS link's counters, fabricating the very symptom #1809 is about.
+ if let since = linkUpSince {
+ let upMs = Int(Double(DispatchTime.now().uptimeNanoseconds &- since.uptimeNanoseconds) / 1_000_000)
+ log(ConnectionReadout.linkEpitaph(upMillis: upMs, inboundFrames: inboundFrames,
+ inboundBytes: inboundBytes, cmdChannelFrames: cmdChannelFrames,
+ realtimeArmed: realtimeArmedAt != nil, ended: endedReason))
+ // #1635: LIVE streams only — the offload persists through `Backfiller` and has its own
+ // accounting, so folding it in would make a healthy bonded sync read as "nothing banked live
+ // for: gravity". Inside the same `linkUpSince` guard for the same reason the epitaph is.
+ log(ConnectionReadout.linkBankedSummary(
+ liveHr: liveHr, liveRr: liveRr, offloadChunks: offloadChunks,
+ offloadHr: offloadHr, offloadRr: offloadRr, offloadGravity: offloadGravity,
+ offloadResp: offloadResp, offloadSkinTemp: offloadSkinTemp, offloadSpo2: offloadSpo2,
+ // nil, not 0: this store does not return a step count, and a zero would read as a fault.
+ offloadSteps: nil))
+ // Frames this link REJECTED, per reason. A per-connection readout, so it sits behind the
+ // Test Centre's Connection domain (D3) — a resync after a lost notification rejects frames
+ // routinely and always did, and that number is explicitly NOT the signal to act on. The one
+ // that is (payload CRC32 verified, envelope not) announces itself when it happens, whether
+ // or not any test mode is on. Silent when the link rejected nothing.
+ if TestCentre.active(.connection), let rejects = router.rejectTally.summaryLine() {
+ state.append(log: rejects, domain: .connection)
+ }
+ }
+ // Clear the tally with the link, so a second teardown for the same drop cannot re-report it.
+ inboundFrames = 0; inboundBytes = 0; cmdChannelFrames = 0
+ liveHr = 0; liveRr = 0; offloadHr = 0; offloadRr = 0
+ offloadGravity = 0; offloadResp = 0; offloadSkinTemp = 0; offloadSpo2 = 0; offloadChunks = 0
+ linkUpSince = nil
+
let timedOut = !intentionalDisconnect && error != nil
let sinceArm = realtimeArmedAt.map { Date().timeIntervalSince($0) }
if marginalRadio.connectionEnded(wasArmed: realtimeArmedAt != nil,
@@ -5007,9 +5851,24 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
}
state.clearBiometrics() // and a stale HR / R-R must not outlive the link either
state.liveFeedActive = false // a drop while Live is open must not leave a stale "Stop live feed"
+ // #1635: an unanswered CLIENT_HELLO produces no write error at all - the link simply drops a few
+ // seconds later - so it never reached the give-up on this platform and nothing could end the loop.
+ // Read it HERE, while `clientHelloWriteAt` and `didBond` are both still valid, and feed it in
+ // through the same split the auth path uses. `countsAsBondRefusal` gates on family, so a 4.0 (which
+ // bonds cleanly) can never latch the suppression.
+ if countsAsBondRefusal(isAuthRefusalStatus: false,
+ helloUnacked: clientHelloWriteAt != nil,
+ alreadyBonded: didBond,
+ family: selectedModel.deviceFamily) {
+ recordWhoop5BondRefusal(authRefusal: false,
+ peripheralUUID: peripheral.identifier.uuidString)
+ }
didBond = false
clientHelloWriteAt = nil // #1635: no hello survives the link that carried it
whoop5RealtimeArmed = false
+ // A user capture remains represented by RawDataSessionStore, but its transport must be re-armed
+ // on the next connection. Keeping this true would make that reconnect attempt a silent no-op.
+ rawCaptureInFlight = false
// The strap forgets the realtime-HR toggle across a disconnect; the post-bond branch re-arms it
// from `wantsRealtime`. Clear only the "what we last sent" flag — `screenWantsRealtime` /
// `keepRealtimeForData` (and thus `wantsRealtime`) are intent and must survive a reconnect so the
@@ -5019,6 +5878,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
clockRequested = false
clockRetries = 0
connectHandshakeDone = false
+ // Mirrors the flag above: a new link has not done the handshake, so it cannot hand over history
+ // until it does. Cleared HERE and nowhere else, so the two can never disagree.
+ state.historyReady = false
cmdNotifyConfirmedActive = false // #34: a fresh connection needs its own notify-confirm + settle
connectSettledSignaled = false
restoreNeedsResubscribe = false // #613: a real reconnect isn't a restore — never force-toggle here
@@ -5032,6 +5894,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
lastSessionEndTrim = nil
backfilling = false
state.backfilling = false
+ state.historyPendingSync = false // #1164: a stale "pending" must not outlive the link
state.syncChunksThisSession = 0
// A mid-sync disconnect bypasses exitBackfilling, so clear the reject counters here too —
// otherwise a stale non-zero count survives until the next beginBackfill. (#77/#91)
@@ -5068,7 +5931,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
Task { @MainActor in await puffinRecorder.flush() } // persist any buffered puffin capture frames
puffinEventLog.close() // release the event-log handle so the file is safe to export
puffinDeepBufferLog.close() // same for the high-rate deep-buffer log (#423)
- Task { @MainActor in await collector?.flushStandardHR() } // persist any buffered 0x2A37 HR
+ Task { @MainActor in await collector?.flushStandardHR(reason: .disconnect) } // persist any buffered 0x2A37 HR
if autoReconnectPausedForBondLoop {
// #747: the bond keeps being refused, so auto-reconnect is paused: we stop hammering a strap that
// can't bond (the epitaph + paused hint were already surfaced when the give-up tripped). The user
@@ -5133,6 +5996,22 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
}
state.append(log: "reconnect n=\(connReconnectCount) failedConnect reason=\(reason)", domain: .connection)
}
+ // PARITY (#1790): Android can now CLEAR this pairing itself, behind a default-off Test Centre
+ // switch, once, after five of these failures — the step guide-line 2 asks the user to take. This
+ // side deliberately cannot, and the blocker is identity rather than a missing call:
+ //
+ // - iOS: the same identity problem, with nothing else to reach for. The guide below already
+ // tells the user to do it themselves, which is the honest answer rather than a claim about
+ // Apple's intent.
+ // - macOS ships IOBluetooth, a separate classic-Bluetooth framework, and whether its device
+ // removal works on a BLE pairing is a question that never arises for us: it addresses devices
+ // by MAC, and we never have one. `epitaphLine`'s doc already says why — the peripheral UUID is
+ // "per-install, not the hardware address". The only bridge left is matching
+ // `IOBluetoothDevice.pairedDevices()` by NAME, and deleting a pairing on a name match removes
+ // the wrong device the moment there are two straps or one has been renamed.
+ //
+ // So both platforms detect this and both give the same advice; only Android can act on it. Do not
+ // close the gap with a name match. Kotlin twin: `StaleBondRemoval.kt`.
if let cbErr = error as? CBError, cbErr.code == .peerRemovedPairingInformation {
state.reconnectGuide = """
Your strap's Bluetooth pairing was reset - usually by a WHOOP firmware update, or the official WHOOP app reconnecting. NOOP works fine on the new firmware; you just need to re-pair:
@@ -5185,6 +6064,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
selectedModel = .persisted
reassembler = Reassembler(family: selectedModel.deviceFamily)
router.family = selectedModel.deviceFamily
+ router.deviceId = deviceId // #1706: attribute this connection's alarm readback
configureCollectorFamily()
// Collection only runs post-bond, so a restored link was already bonded;
// seed those flags now. `didWriteValueFor` won't re-fire on its own.
@@ -5204,7 +6084,13 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
clockRequested = false
clockRetries = 0
// Ensure the store is ready before restored BLE data arrives (idempotent; no-op if already built).
- Task { @MainActor in await bootstrapStore() }
+ Task { @MainActor in
+ await bootstrapStore()
+ // #1881: `didConnect` never fires on this path (see above), so the identity adoption that lives
+ // there would be skipped for a restored link — the very path a radio toggle and a relaunch take.
+ // After `bootstrapStore`, because it is what creates `registryStore`.
+ if let p = self.peripheral ?? self.restoredPeripheral { self.adoptSourceIdentity(for: p) }
+ }
if p.state == .connected {
state.connected = true
// #613: the inherited notify subscriptions come back reported-active but dead. Force one real
@@ -5274,9 +6160,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
case BLEManager.batteryService:
peripheral.discoverCharacteristics([BLEManager.batteryChar], for: s)
case BLEManager.disService:
- // #520: read-only identity strings. Discovered here, but READ post-bond (below).
+ // #520: read-only identity strings. Discovered here; READ post-bond, or on a suppressed
+ // link via `readDisIdentityUnbonded` (#1635) — a strap that never bonds has no other
+ // source for its firmware at all.
peripheral.discoverCharacteristics(
- [BLEManager.disSerialChar, BLEManager.disHwRevChar], for: s)
+ BLEManager.disChars.map { $0 }, for: s)
case BLEManager.whoop5Service:
// EXPERIMENTAL WHOOP 5.0/MG path: discover the puffin command + notify characteristics
// so we can send CLIENT_HELLO and receive frames. Live HR/battery still arrive over the
@@ -5337,7 +6225,25 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// fires for a 5/MG strap. Live HR/battery come from the standard profiles; this just
// opens the puffin session. Unverified on real MG hardware.
cmdCharacteristic = c
- if let hello = selectedModel.deviceFamily.clientHello {
+ // #1635: once the give-up has latched, the hello is what ends the link — skip it and let
+ // the standard-profile HR stream keep running. Consumed unconditionally so an explicit
+ // Connect's single retry belongs to this session and cannot leak into a later automatic one.
+ let helloUserAsked = helloRetryRequested
+ helloRetryRequested = false
+ let helloSuppressed = HelloSuppressionStore.suppressed(peripheral.identifier.uuidString)
+ if !shouldSendClientHello(suppressedForDevice: helloSuppressed, userInitiated: helloUserAsked) {
+ // #1635: says what happened and what has actually worked, not "try again". This strap
+ // refuses the handshake, so a retry is the one thing that cannot help - and suggesting
+ // it invites the hammering this suppression exists to stop. Mirrors the user-facing
+ // hint, which lost the same ending.
+ log("WHOOP 5/MG: CLIENT_HELLO suppressed for this strap - it was never acknowledged and the write is what drops the link. Staying on live HR (not fully paired). Some straps have paired again after being put in pairing mode (tap until the LEDs flash blue) (#1635).")
+ state.pairingHint = BondRefusalGiveUp.helloSuppressedHint()
+ // The unbonded DIS attempt rides HERE and nowhere else: this is the only 5/MG state
+ // known to be stable - the handshake is off and the link is holding - and it is now
+ // PERMANENT rather than transient, so without it a suppressed strap never learns its
+ // firmware or whether it is an MG at all (#490).
+ scheduleUnbondedDisRead()
+ } else if let hello = selectedModel.deviceFamily.clientHello {
// CONTRIBUTOR FIX (issue #17 — diagnosed from the logs, unverified on hardware here):
// write CLIENT_HELLO with .withResponse so CoreBluetooth runs just-works bonding when
// the link needs authenticating, AND so didWriteValueFor fires. That callback is where
@@ -5370,13 +6276,22 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
default: break
}
requestNotify(c, on: peripheral, reason: "discovery")
- case BLEManager.disSerialChar, BLEManager.disHwRevChar:
- // #520: CAPTURE ONLY — the read is issued post-bond (a 5/MG refuses standard reads on an
- // unencrypted link, see #490). These are read-only identity strings: never subscribed
- // (no notify property) and never written. Must be handled BEFORE `default`, or they'd be
- // retained as puffin notify characteristics.
+ case BLEManager.disSerialChar, BLEManager.disHwRevChar, BLEManager.disFwRevChar,
+ BLEManager.disManufacturerChar, BLEManager.disModelNumberChar, BLEManager.disSwRevChar:
+ // #520: CAPTURE ONLY — the read is issued post-bond, and since #1635 ALSO on a suppressed
+ // link that will never bond (see `readDisIdentityUnbonded`; whether a 5/MG answers a
+ // standard read unencrypted is the open question #490 asks). These are read-only identity
+ // strings: never subscribed (no notify property) and never written. Must be handled BEFORE
+ // `default`, or they'd be retained as puffin notify characteristics.
if c.uuid == BLEManager.disSerialChar {
disSerialCharacteristic = c
+ } else if c.uuid != BLEManager.disHwRevChar {
+ // The identity extras are read straight off the discovered service, so they need no
+ // dedicated property — but they must still be claimed here or `default` would retain
+ // them as puffin notify characteristics.
+ if !disExtraCharacteristics.contains(where: { $0.uuid == c.uuid }) {
+ disExtraCharacteristics.append(c)
+ }
} else {
disHwRevCharacteristic = c
}
@@ -5442,19 +6357,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// threshold (the pairing hint has had several cycles to be acted on), pause auto-reconnect so
// we stop hammering a strap that can't bond, write the one-line epitaph (opaque id only, no
// PII), and surface the honest paused hint. A genuine bond or a manual reconnect re-arms it.
- if bondGiveUp.recordRefusal() {
- autoReconnectPausedForBondLoop = true
- bondLoopPausedAt = Date() // starts the #78 hole-4 salvage-probe floor
- // #1539: same reasoning as the #617 trip — park a connect now so this pause can end
- // while the app is backgrounded, not only when the user next opens it.
- standingConnectWhilePausedIfDue(justTripped: true)
- let opaque = BondRefusalGiveUp.opaqueId(fromLocalUUID: peripheral.identifier.uuidString)
- log(BondRefusalGiveUp.epitaphLine(refusals: bondGiveUp.refusals, opaqueId: opaque))
- state.pairingHint = BondRefusalGiveUp.pausedHint()
- if TestCentre.active(.connection) {
- state.append(log: "bond gaveUp refusals=\(bondGiveUp.refusals) id=\(opaque) (auto-reconnect paused)", domain: .connection)
- }
- }
+ // #1635: the trip itself now lives in recordWhoop5BondRefusal, shared with the
+ // unanswered-handshake path at disconnect, so the two causes cannot drift apart. This
+ // one is an AUTH refusal — the strap actively declined — so it still pauses.
+ recordWhoop5BondRefusal(authRefusal: true,
+ peripheralUUID: peripheral.identifier.uuidString)
}
// Multi-WHOOP stale-pin recovery (#52). When a stale registry pin points at a strap that keeps
// refusing the encrypted bond ("Encryption/Authentication is insufficient") but a DIFFERENT
@@ -5486,6 +6393,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// genuinely up keeps its notifications either way.
let isHelloChar = characteristic.uuid == BLEManager.whoop5CmdWriteChar
let helloOutstanding = clientHelloWriteAt != nil
+ // #1883: compute the elapsed time BEFORE clearing the window, so the timing tell can use it.
+ // The elapsed time is the whole signal Apple has — CoreBluetooth exposes no bond state, so
+ // a completion faster than one connection interval is the one tell that the callback came
+ // from the local stack rather than the strap (#1635).
+ let helloElapsedMs = clientHelloWriteAt.map { Int(Date().timeIntervalSince($0) * 1000) }
// Consume the window ONLY for the hello's own completion. A foreign completion that cleared it
// would make a genuine ack arriving afterwards look unsolicited, costing a real bond.
//
@@ -5505,6 +6417,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
isWhoop5: true
) {
didBond = true
+ // #1635: the handshake demonstrably works on this strap - drop the latch so a later
+ // transient failure starts from a clean slate rather than inheriting an old verdict.
+ HelloSuppressionStore.setSuppressed(peripheral.identifier.uuidString, false)
+ bondGiveUp.reset()
state.bonded = true
state.encryptedBond = true // genuine encrypted bond (not the live-HR shortcut) — #69
bondedAt = Date() // #617: start the bond→drop stopwatch for the bond-loop detector
@@ -5516,6 +6432,14 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
noteGenuineBond(of: peripheral) // #52: this strap bonds fine; clears any pin-refusal streak
emitConnectionBondState("encryptedBond family=whoop5 (CLIENT_HELLO acked)")
log("WHOOP 5/MG: CLIENT_HELLO acked — link established; subscribing notify chars (experimental).")
+ // #1883: Apple has no link-encryption state to verify the bond against. A completion
+ // faster than one connection interval did not come from the strap — that is the
+ // signature of #1635's false bond. Log it as UNVERIFIED without changing behavior,
+ // because on Apple there is no alternative source of truth and refusing to bond would
+ // break every strap that genuinely bonds.
+ if let elapsed = helloElapsedMs, let timingLine = ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: elapsed) {
+ log(timingLine)
+ }
}
for c in whoop5NotifyCharacteristics where !c.isNotifying || restoreNeedsResubscribe {
requestNotify(c, on: peripheral, reason: "post-bond puffin") // #613: force re-arm on restore
@@ -5545,6 +6469,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
if !whoop5SessionStarted {
whoop5SessionStarted = true
connectHandshakeDone = true // unblocks beginBackfill()'s guard
+ state.historyReady = true // and the sync controls, which must not offer what it declines
log("WHOOP 5/MG: connect handshake done — backfill unblocked")
noteRebootReconnectIfNeeded()
// Re-apply the Broadcast-HR device-config flag if the user opted in (#181).
@@ -5557,7 +6482,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// Hardware-validated ordering (#78 fork).
send(.setClock, payload: BLEManager.setClockPayload())
send(.getClock, payload: [])
- log("WHOOP 5/MG: clock synced (set/get) — strap can persist history now")
+ // #1823: say what was SENT, not what resulted. This previously read "clock synced —
+ // strap can persist history now", logged before any reply had arrived, so a log could
+ // assert the clock was set while the Devices readout said 1970/71. The strap's actual
+ // answer now arrives as the "clock: …" ack line from FrameRouter.
+ log("WHOOP 5/MG: SET_CLOCK + GET_CLOCK sent — awaiting the strap's answer")
log("WHOOP 5/MG: scheduling first historical offload (connect)")
// Deferred ~1.5s so the puffin notify subscriptions settle before SEND_HISTORICAL_DATA,
// mirroring the WHOOP4 kick. requestSync → beginBackfill is itself gated on
@@ -5594,6 +6523,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// type-47 fine because it runs the sequence once on a stable connection; the app stormed it.
guard !connectHandshakeDone else { return }
connectHandshakeDone = true
+ state.historyReady = true
noteRebootReconnectIfNeeded()
backfillStarted = true
@@ -5763,11 +6693,19 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// log was indistinguishable from one where the strap never answered, which is why a broken decode
// survived unnoticed. The raw-frame dump above is unconditional for the same reason. If a firmware
// revision ever shifts these fields again, the rejection must be visible rather than silent.
- if let pages = DataRange.pagesBehind(from: frame, cmdOff: cmdOff) {
- log("Strap backlog pages behind: \(pages) (#689 — GET_DATA_RANGE ring backlog, diagnostic only)")
- } else {
- log("Strap backlog pages behind: not decodable from this frame (#689 — offsets may have moved; "
- + "the raw frame above is the input). Diagnostic only, sync is unaffected.")
+ // ...but NOT on the PENDING(2) ack. GET_DATA_RANGE answers twice — a short ack, then the payload —
+ // which Framing's result-code table already records ("2=PENDING precedes SUCCESS on
+ // GET_DATA_RANGE"). The ack carries no ring pointers at all, so decoding it failed every time and
+ // reported a decode problem that did not exist: one "offsets may have moved" per sync, on healthy
+ // hardware, pointing the reader at an alignment bug rather than at the ack. Skip it here; the
+ // SUCCESS frame still logs its failure loudly, which is the case the paragraph above protects.
+ if !DataRange.isPendingResponse(frame, cmdOff: cmdOff) {
+ if let pages = DataRange.pagesBehind(from: frame, cmdOff: cmdOff) {
+ log("Strap backlog pages behind: \(pages) (#689 — GET_DATA_RANGE ring backlog, diagnostic only)")
+ } else {
+ log("Strap backlog pages behind: not decodable from this frame (#689 — offsets may have moved; "
+ + "the raw frame above is the input). Diagnostic only, sync is unaffected.")
+ }
}
if let newest = BLEManager.dataRangeNewestUnix(from: frame) {
// #695: the sync-affecting side-effects (strapNewestTs, backfill window, LiveState range) only
@@ -5799,6 +6737,27 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// UNIVERSAL clock-drift snapshot (RTC cluster #531/#767/#804/#812): bank the [oldest, newest]
// window onto LiveState UNCONDITIONALLY (observability, not gated) for the export assembler.
if feedsSync { state.setStrapRange(newestUnix: newest, oldestUnix: (oldest.map { $0 < newest } ?? false) ? oldest : nil) }
+ // #1164: recompute the "strap has banked records newer than our frontier" flag so the Today
+ // Rest card can show "Pending sync" right after connect (before the first offload starts),
+ // not only after an offload completes. The frontier read is async; the flag settles a beat
+ // after the range lands. feedsSync only (5/MG sync is unconfirmed — leave it untouched).
+ if feedsSync {
+ let newestForPending = newest
+ Task { @MainActor in
+ let frontier = await collector?.latestHRSampleTs() ?? nil
+ if let f = frontier {
+ // #928/#1012: a strap whose clock is set in the FUTURE reads ahead of ANY
+ // frontier, so without this the gap never closes and Rest is pinned to "Pending
+ // sync" for good. The phantom-gap guard used at the post-offload site cannot apply
+ // here: no offload has run yet, so there is no row evidence to weigh. The first
+ // completed pass corrects it.
+ let wallNowP = Int(Date().timeIntervalSince1970)
+ state.historyPendingSync =
+ !BackfillContinuation.isFutureDatedNewest(newestForPending, wallNowUnix: wallNowP)
+ && (newestForPending - f) > BackfillContinuation.defaultBehindGapSeconds
+ }
+ }
+ }
// Connection test mode: promote the CLOCK-DRIFT picture to one upfront tagged line (#767/#754).
if TestCentre.active(.connection) {
let line = ConnectionTrace.clockDriftLine(
@@ -5814,12 +6773,20 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
if let error {
+ // A DIS refusal is a FINDING, not noise — report it specifically and latch it, or a capture
+ // cannot tell a refusal from a read that was never issued (#490, #1635).
+ noteDisReadFailure(characteristic.uuid, error)
log("Notify update failed for \(characteristic.uuid): \(error.localizedDescription)")
return
}
guard let data = characteristic.value else { return }
let bytes = [UInt8](data)
lastDataAt = Date() // feed the liveness watchdog on every notification
+ // #1809: count BEFORE the per-characteristic switch below, so the tally covers every inbound
+ // frame including ones no branch consumes - the epitaph must answer "did anything arrive at all".
+ inboundFrames += 1
+ inboundBytes += bytes.count
+ if characteristic.uuid == BLEManager.cmdNotifyChar { cmdChannelFrames += 1 }
switch characteristic.uuid {
case BLEManager.heartRateChar:
@@ -5830,6 +6797,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
if selectedModel.deviceFamily == .whoop5, !state.bonded {
state.bonded = true
log("WHOOP 5/MG: live HR streaming — marking the link established (experimental).")
+ // #1635: a 5/MG has no confirmed-write handshake, so the keep-alive (and with it the
+ // liveness watchdog) is started HERE, on the bonded transition, exactly as the Kotlin twin
+ // does. The other two start sites are post-bond, which a suppressed strap never reaches —
+ // and this also covers an unbonded 5/MG that has not latched yet.
+ startKeepAlive()
}
case BLEManager.batteryChar:
// 0x2A19 = percent — 5/MG ONLY. The WHOOP 4.0's 0x2A19 is a stub constant 100 (real value =
@@ -5847,20 +6819,65 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
disHwRev = String(decoding: bytes, as: UTF8.self)
.trimmingCharacters(in: CharacterSet(charactersIn: "\0").union(.whitespacesAndNewlines))
noteWhoop5VariantFromDIS()
+ case BLEManager.disFwRevChar:
+ disFirmware = String(decoding: bytes, as: UTF8.self)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0").union(.whitespacesAndNewlines))
+ // FALLBACK only. The puffin report is the strap's own account of what it is running and lands
+ // later in the connect; a value that appeared and then changed would be worse than one that
+ // arrived once, so this yields rather than racing it.
+ if shouldPublishDisFirmware(disFirmware: disFirmware, alreadyDecoded: state.strapFirmware) {
+ state.strapFirmware = disFirmware
+ // #1634: persist it against THIS strap. The per-device key has readers (DevicesView, the
+ // debug export) but had no writer at all - the decoded path cannot key on identity, which
+ // its own comment concedes - so `perDevice` always resolved nil and iOS fell back to a
+ // global value that is wrong the moment a second strap exists. This site DOES have the
+ // peripheral, so it is the one place that can attribute a firmware honestly. Kotlin twin:
+ // NoopPrefs.setFirmwareFor beside the same publish.
+ if let key = FirmwareAttribution.prefKey(peripheralId: peripheral.identifier.uuidString),
+ let fw = disFirmware {
+ UserDefaults.standard.set(fw, forKey: key)
+ }
+ // Named as the DIS source, because it is not the same reading as the decoded one a 4.0
+ // shows and a capture must not have to guess which it is looking at.
+ log("DIS: firmware=\(disFirmware ?? "?") (standard profile, no bond required)")
+ } else {
+ let shown = (disFirmware?.isEmpty ?? true) ? "?" : (disFirmware ?? "?")
+ log("DIS: firmware=\(shown) not published — a decoded value already stands")
+ }
+ case BLEManager.disManufacturerChar, BLEManager.disModelNumberChar, BLEManager.disSwRevChar:
+ // Diagnostic only: nothing gates on these, but they cost one read each and are exactly what
+ // is missing when someone reports an unidentified strap.
+ let v = String(decoding: bytes, as: UTF8.self)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\0").union(.whitespacesAndNewlines))
+ if !v.isEmpty { log("DIS: \(characteristic.uuid.uuidString) = \(v)") }
case BLEManager.dataNotifyChar,
BLEManager.cmdNotifyChar,
BLEManager.eventNotifyChar:
// Reassemble (no-op for already-complete frames) then route each complete frame.
- for frame in reassembler.feed(bytes) {
+ // A byte run whose declared total is below the family minimum is dropped INSIDE the
+ // reassembler and reaches no parser and no archive, so its monotonic drop count is folded
+ // into the connection's reject tally right after the feed that may have grown it —
+ // otherwise it would disappear without trace.
+ let completedFrames = reassembler.feed(bytes)
+ router.noteReassemblerDrops(reassembler.belowMinimumLengthDrops)
+ for frame in completedFrames {
if backfilling, BLEManager.isOffloadFrame(frame, family: .whoop4) {
// Historical replay is bulk sync traffic, not live UI traffic. Feed it only to
// the Backfiller; parsing every record through FrameRouter updates SwiftUI for
// no user-visible benefit and can make the app feel hung during long offloads.
+ // …but the CONNECTION'S REJECT TALLY must still see it (D3): the router is what
+ // counts rejections, and skipping it left the one counter the hardware run's abort
+ // criterion reads blind in exactly the traffic where an emptied-then-acked section
+ // is a permanent loss. The verdict is formed once here, from the verifier, and
+ // handed to the counter — no parse on this path, which is why it was skipped.
+ router.noteOffloadFrameVerdict(verifyFrame(frame, family: .whoop4))
armBackfillTimeout()
routeBackfillFrame(frame)
// …but a REAL-TIME physical gesture (double-tap / wrist) must still fire even mid-
// offload (#69). Gated on ts≈now so replayed historical EVENTs (old ts) are ignored.
router.dispatchLiveGestureIfFresh(frame: frame, now: strapClockNow)
+ // …and the strap's own console narration, which it emits precisely DURING a sync.
+ router.mirrorStrapConsoleIfPresent(frame: frame)
continue
}
// #47: decode this live WHOOP4 frame ONCE here and thread the result to every consumer
@@ -5872,6 +6889,30 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// the router + collector re-checks the invariant).
let parsed = parseFrame(frame, family: .whoop4)
router.handle(parsed: parsed, frame: frame) // live/UI path
+ //
+ // WHAT IS AND IS NOT GATED BELOW (standing risk, recorded rather than fixed here).
+ //
+ // Everything from here on branches on a RAW BYTE COMPARE of the command opcode, before
+ // any integrity verdict. That is deliberate and unchanged by the frame-integrity work:
+ // the six state-driving consumers are gated (router, history-metadata classifier, both
+ // extractors, clock correlation, and the data-range reply below), and this change does
+ // not claim "every frame consumer".
+ //
+ // Of the probe replies dispatched here, these verify before reading a field, inside
+ // their own decoder: the feature-flag probe and the device-config READ probe (both
+ // through `verifyFrame` in WhoopProtocol), the R22 read-back and the ECG/Broadcast-HR
+ // gate READ-BACKS (through the same probe parser), and the 5/MG ECG probe.
+ //
+ // These do NOT, and stay that way for now — none drives live state or the offload:
+ // • the extended-battery probe (#592) and the body-location probe (#690): each reads
+ // the reply's bytes, states a finding about the strap in the Devices dialog, and
+ // persists its payload hex to UserDefaults for the next capture diff;
+ // • the WRITE-ACK branches (R22 disable, ECG gate, Broadcast-HR gate), which read a
+ // result code straight out of the frame for their transcripts.
+ // The exposure is a diagnostic that asserts something the strap never said — a real
+ // violation of "a diagnostic may only assert what it can attribute", but a reading, not
+ // a data path. Hardening them is a scope decision, not this change's.
+ //
// #592: the read-only extended-battery probe's COMMAND_RESPONSE — format + publish it for the
// Devices dialog (raw hex + payload triage + capture diff). Sibling of the #451 dump below.
if frame.count > 6, frame[6] == WhoopCommand.getExtendedBatteryInfo.rawValue {
@@ -5898,7 +6939,17 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// #695: WHOOP4 data-range reply — cmd byte @6. The 5/MG reply (puffin envelope, cmd @10) is
// handled in the 5/MG case below; both call handleDataRangeResponse so 5/MG now gets the same
// newest/oldest window (strapNewestTs / #547 backfill gate) + diagnostics it previously missed.
- if frame.count > 6, frame[6] == WhoopCommand.getDataRange.rawValue {
+ //
+ // GATED ON THE FULL VERDICT, unlike the probe replies above, because this one has OFFLOAD
+ // effect: the newest/oldest words it yields become the plausibility window every drained
+ // record is checked against (#547). A damaged reply that narrows that window makes the
+ // real records of the same sync fall through it — the section then persists nothing, and
+ // is acknowledged anyway, which is the same permanent loss the integrity gate exists to
+ // stop. `parsed` is the single parse from the seam above; this costs one Bool, no reparse.
+ // The decision itself lives in `DataRange.acceptsReply` so a WhoopProtocol test can hold
+ // it — nothing compiles this seam, so a gate written only here is a gate nothing pins.
+ if DataRange.acceptsReply(frame, cmdOff: 6, opcode: WhoopCommand.getDataRange.rawValue,
+ verdictOK: parsed.ok) {
handleDataRangeResponse(frame, cmdOff: 6, feedsSync: true)
}
// Clock correlation runs in both live and backfill modes. Once established it
@@ -5934,7 +6985,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// timestamps are already real-unix seconds.) Live HR/battery still also come from the
// standard 0x2A37 / 0x2A19 profiles handled above.
if BLEManager.whoop5NotifyChars.contains(characteristic.uuid) {
- for frame in reassembler.feed(bytes) {
+ // Same fold as the WHOOP 4.0 path above: byte runs dropped below the family minimum.
+ let completedFrames = reassembler.feed(bytes)
+ router.noteReassemblerDrops(reassembler.belowMinimumLengthDrops)
+ for frame in completedFrames {
let isOffload = backfilling && BLEManager.isOffloadFrame(frame, family: .whoop5)
noteWhoop5R22Telemetry(frame, duringOffload: isOffload) // #174 deep-data telemetry
// Durable EVENT-frame log for deep-data research (#103) — BEFORE the offload
@@ -5946,21 +7000,30 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// reverse-engineering — its own file the bulk-capture eviction never churns.
// BEFORE the offload branch so it catches the burst; no-op unless capture is on.
puffinDeepBufferLog.appendIfDeepBuffer(frame: frame, char: characteristic.uuid, isOffload: isOffload)
+ stopUnexpectedRealtimeImu(frame, isOffload: isOffload)
// #423: the queryable twin of that diagnostics line — persist the decoded 100 Hz 6-axis
- // IMU samples into the rawImuSample table when raw capture is on (same gate, gated inside).
- collector?.storeRawImu(frame: frame)
if isOffload {
// Same policy as WHOOP4: historical offload frames are bulk sync traffic.
// Keep them out of the live UI parser during backfill and let Backfiller
// preserve/order/process them in the sliced drain.
+ // Same tally hand-off as the WHOOP 4.0 loop, and for the same reason (D3):
+ // the router counts rejections, so a frame that skips it would be invisible
+ // to the counter the hardware run is judged on. One verdict, no parse.
+ router.noteOffloadFrameVerdict(verifyFrame(frame, family: .whoop5))
armBackfillTimeout()
routeBackfillFrame(frame)
// A real-time double-tap / wrist gesture still fires during a 5/MG offload (which
// runs for minutes, #69); the ts≈now gate rejects replayed historical EVENTs.
router.dispatchLiveGestureIfFresh(frame: frame, now: strapClockNow)
+ // …and the strap's own console narration, which it emits precisely DURING a sync.
+ router.mirrorStrapConsoleIfPresent(frame: frame)
continue
}
router.handle(frame: frame)
+ // The same split as the WHOOP 4.0 loop above, and for the same reasons: the probe
+ // dispatches below branch on a raw opcode compare; the ones that verify do it inside
+ // their own decoder, the extended-battery / body-location probes and the write-ack
+ // branches do not, and are recorded there as a standing risk rather than hardened.
// #592: a 5/MG extended-battery probe COMMAND_RESPONSE (puffin envelope: type @8, cmd
// @10). Format + publish it for the Devices dialog, exactly like the 4.0 path above.
if frame.count > 10, frame[8] == 0x24, frame[10] == WhoopCommand.getExtendedBatteryInfo.rawValue {
@@ -6014,7 +7077,14 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// the ECG records arrive under. `ecgProbeArmed` is false outside a user-initiated
// run, so this costs one Bool read on every other frame.
if ecgProbeArmed { noteEcgProbeFrame(frame) }
- if frame.count > 10, frame[8] == 0x24, frame[10] == WhoopCommand.getDataRange.rawValue {
+ // Gated on the FULL verdict for the same reason as the 4.0 path: this reply sets the
+ // window the offload judges its records against. The verdict is taken here rather
+ // than threaded from the seam because the 5/MG loop hands the router raw bytes; a
+ // data-range reply arrives once or twice per connection, so the parse is not a cost
+ // on the frame flood.
+ if frame.count > 10, frame[8] == 0x24,
+ DataRange.acceptsReply(frame, cmdOff: 10, opcode: WhoopCommand.getDataRange.rawValue,
+ verdictOK: parseFrame(frame, family: .whoop5).ok) {
// feedsSync: false — #695 diagnostic-only on 5/MG: log the dump/backlog/newest/oldest so
// a strap log validates the decode, but DON'T feed strapNewestTs/backfill/state yet.
// Flip to true once a real 5.0/MG strap confirms the newest/oldest are correct.
diff --git a/Strand/BLE/ClientHelloOutcome.swift b/Strand/BLE/ClientHelloOutcome.swift
index 326a6a25c7..5018b71075 100644
--- a/Strand/BLE/ClientHelloOutcome.swift
+++ b/Strand/BLE/ClientHelloOutcome.swift
@@ -84,5 +84,41 @@ enum ClientHelloOutcome {
return "CLIENT_HELLO outcome: completion from \(whereFrom)\(st) with NO hello outstanding — not a bond,"
+ " so the link stays unbonded (#1635)"
}
+
+ /// The shortest an ATT round trip can plausibly take, in milliseconds.
+ ///
+ /// BLE's minimum connection interval is 7.5ms. A peripheral may answer inside the same connection
+ /// event, so this is not a hard floor and is NOT used to reject anything — it decides only whether
+ /// the log points the reader at the timing. It earns its place from the field distribution, which is
+ /// starkly bimodal: across 41 captures every hello either "completed" in 0, 4, 5 or 7ms, or produced
+ /// no callback at all about 3150ms later. Nothing in between, and 0ms cannot be a round trip at all.
+ ///
+ /// Byte-identical to the Android twin `MIN_PLAUSIBLE_ATT_ROUND_TRIP_MS`.
+ static let minPlausibleAttRoundTripMs = 8
+
+ /// #1883: a timing tell for Apple, where CoreBluetooth exposes no link-encryption state.
+ ///
+ /// On Android, `helloCompletionProvesEncryptedBond(osBonded) = osBonded` — only the OS bond state
+ /// attests encryption. CoreBluetooth exposes no bond state at all, so Apple has nothing to supply
+ /// for that check. The gap is real: a completion that is faster than one connection interval did
+ /// NOT come from the strap, and that is exactly the signature of #1635's false bond — a
+ /// `DISABLE_ALARM` completion misread as a hello ack because it arrived in 5ms.
+ ///
+ /// This line does NOT change any behavior. The bond still proceeds, the handshake still runs, and
+ /// `encryptedBond` is still set — because on Apple there is no alternative source of truth, and
+ /// refusing to bond would break every strap that genuinely bonds. The line only says what is
+ /// UNKNOWN: that the completion was suspiciously fast and the link's encryption state cannot be
+ /// verified on this platform. It is the diagnostic the issue's direction 2 asks for, and it is the
+ /// one that would have made #1635 visible on Apple instead of only on Android.
+ ///
+ /// Pure. No Kotlin twin — this is Apple-only by design (Android has `helloAckedWithoutEncryptionLine`
+ /// for the case where the OS bond state IS available and disagrees).
+ static func unverifiedBondTimingLine(elapsedMs: Int) -> String? {
+ guard elapsedMs < minPlausibleAttRoundTripMs else { return nil }
+ return "CLIENT_HELLO outcome: acked after \(elapsedMs)ms — under one BLE connection interval,"
+ + " so the callback most likely came from the local stack rather than the strap. CoreBluetooth"
+ + " exposes no link-encryption state, so the bond is UNVERIFIED on this platform (#1883, #1635)."
+ + " The handshake proceeds because there is no alternative source of truth on Apple."
+ }
}
diff --git a/Strand/BLE/Commands.swift b/Strand/BLE/Commands.swift
index f8ecac3628..eb0f554a2d 100644
--- a/Strand/BLE/Commands.swift
+++ b/Strand/BLE/Commands.swift
@@ -92,6 +92,11 @@ public enum WhoopCommand: UInt8, CaseIterable {
/// payloads. If probing (#592): send THIS curated number first and capture the response — do NOT lead
/// with the decompile's 87, an opcode unknown to this table (the curated-safe-subset rule).
case getExtendedBatteryInfo = 98
+ /// The 5/MG battery pack's fuel gauge, read THROUGH the strap — the pack talks to the strap over its
+ /// own link, so there is no peripheral to connect to and nothing to scan for. `BatteryPackInfo` has
+ /// decoded this reply since the offsets were captured; until now nothing sent the command, so the
+ /// decoder had no caller. A 4.0 never answers it (its pack is voltage-only via 98).
+ case getBatteryPackInfo = 151
/// #690: read-only body-location/status probe. Documented in the WHOOP protocol; driven only by the
/// user-triggered, Test-Centre-gated probeBodyLocationAndStatus(). Decoded to a diagnostic report only.
case getBodyLocationAndStatus = 84
@@ -226,6 +231,7 @@ public enum WhoopCommand: UInt8, CaseIterable {
case .enterHighFreqSync: return "Enter High-Freq Sync"
case .exitHighFreqSync: return "Exit High-Freq Sync"
case .getExtendedBatteryInfo:return "Get Extended Battery Info"
+ case .getBatteryPackInfo: return "Get Battery Pack Info"
case .getBodyLocationAndStatus:return "Get Body Location And Status"
case .startFeatureFlagKeyExchange: return "Start Feature-Flag Key Exchange"
case .sendNextFeatureFlag: return "Send Next Feature Flag"
diff --git a/Strand/BLE/DisUnbondedRead.swift b/Strand/BLE/DisUnbondedRead.swift
new file mode 100644
index 0000000000..c0ec3eb30d
--- /dev/null
+++ b/Strand/BLE/DisUnbondedRead.swift
@@ -0,0 +1,83 @@
+import Foundation
+
+/// Should NOOP try reading the Device Information Service on a 5/MG that has NOT bonded?
+///
+/// `readDisIdentity` is issued only inside the post-bond handshake, so a strap that never bonds never
+/// reads DIS at all — and since #1635 the suppression makes that state PERMANENT rather than transient.
+/// The Devices screen shows a WHOOP 4.0 with its firmware beside a 5/MG with none. The firmware, the
+/// serial and the MG-vs-5.0 hardware revision all sit behind the same bond that never happens.
+///
+/// Whether they NEED that bond is unproven, and the evidence is one-sided in an uncomfortable way.
+/// Standard NOTIFICATIONS demonstrably work unbonded — live HR and battery both arrive that way on
+/// exactly these straps — but nothing shows a standard READ working unbonded, and this file's own
+/// platform carries the #490 note that a 5/MG refuses standard reads on an unencrypted link. A read is
+/// also the same class of operation as the write that has been provoking the teardown, so this can
+/// plausibly cost the stable link the suppression just bought.
+///
+/// Hence: once per device, and never again after a refusal. [previouslyRefused] is persisted, so a strap
+/// that says no says it once rather than on every reconnect forever. That makes the experiment
+/// self-limiting in the same way the hello suppression is — "keeps trying something that will never
+/// work" is the failure mode this whole area keeps producing.
+///
+/// A refusal is a RESULT, not a defeat: it settles #490, which no capture has ever done.
+///
+/// Kotlin twin: `com.noop.ble.shouldReadDisUnbonded`.
+func shouldReadDisUnbonded(
+ isWhoop5: Bool,
+ bonded: Bool,
+ alreadyReadThisLink: Bool,
+ previouslyRefused: Bool
+) -> Bool {
+ if !isWhoop5 { return false }
+ if bonded { return false } // the post-bond path already covers this
+ if alreadyReadThisLink { return false }
+ return !previouslyRefused
+}
+
+/// Persisted key for "this strap refused an unbonded DIS read". Per device and lowercased, for the same
+/// reason the hello-suppression key is.
+///
+/// DIVERGENCE FROM ANDROID (deliberate, PII): the identifier here is the CoreBluetooth-local peripheral
+/// UUID, per-install rather than the hardware address. Same key SHAPE, different kind of identifier.
+///
+/// Kotlin twin: `com.noop.ble.disRefusedPrefKey`.
+func disRefusedPrefKey(_ peripheralId: String?) -> String? {
+ guard let raw = peripheralId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
+ return nil
+ }
+ return "noop.disRefused.\(raw.lowercased())"
+}
+
+/// The line for a DIS read that came back with a failure status.
+///
+/// Without it the read fails into the generic notify-failure line, so a capture cannot tell a refusal
+/// from a read that was never issued. That is the same ambiguity that made the CLIENT_HELLO failure
+/// unreadable for eleven weeks, and it is not worth reproducing.
+///
+/// Insufficient authentication / encryption are the interesting answers: they say the strap requires an
+/// encrypted link for this read, which settles #490 and means the firmware simply cannot be had without
+/// a bond.
+///
+/// Pure. Byte-identical to the Kotlin `disReadFailureLine`.
+func disReadFailureLine(uuid: String, status: String) -> String {
+ "DIS: read of \(uuid) failed \(status) — the strap declined it on this link. If this is an insufficient-authentication or -encryption status, DIS needs an encrypted bond and the firmware cannot be read without one (#490)"
+}
+
+/// Should the standard Device Information Service firmware string be published for this strap?
+///
+/// A 5/MG that never completes the puffin handshake has no firmware to show, because the only source
+/// NOOP reads it from is a framed command that needs the bond. DIS `0x2A26` sits in the same service the
+/// serial and hardware revision come from; it was simply never asked for on this platform.
+///
+/// DIS is a FALLBACK, never an override. The puffin value is the strap's own report of the firmware it
+/// is running and is what the 4.0 has always shown; DIS is whatever the device chose to publish in its
+/// standard profile, and the two are not guaranteed to agree. So this yields to anything already decoded
+/// rather than racing it — the decode lands later in the connect, and a value that appeared and then
+/// changed would be worse than one that arrived once.
+///
+/// Kotlin twin: `com.noop.ble.shouldPublishDisFirmware`.
+func shouldPublishDisFirmware(disFirmware: String?, alreadyDecoded: String?) -> Bool {
+ guard let dis = disFirmware, !dis.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
+ guard let decoded = alreadyDecoded else { return true }
+ return decoded.trimmingCharacters(in: .whitespaces).isEmpty
+}
diff --git a/Strand/BLE/FTMSSource.swift b/Strand/BLE/FTMSSource.swift
index 0293558998..ef15fbbad6 100644
--- a/Strand/BLE/FTMSSource.swift
+++ b/Strand/BLE/FTMSSource.swift
@@ -200,7 +200,7 @@ extension FTMSSource: @preconcurrency CBCentralManagerDelegate {
seenPeripherals[id] = peripheral
let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let name = advName ?? peripheral.name ?? "Gym Equipment"
- if firstSight { log("FTMS: found \(name) (\(id)) rssi \(RSSI.intValue)") }
+ if firstSight { log("FTMS: found \(LiveState.logSafeDeviceName(name)) (\(id)) rssi \(RSSI.intValue)") }
let machine = DiscoveredMachine(id: id, name: name, rssi: RSSI.intValue)
if let idx = discovered.firstIndex(where: { $0.id == id }) {
discovered[idx] = machine
diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift
index 7186afea37..2183dafd9c 100644
--- a/Strand/BLE/FrameRouter.swift
+++ b/Strand/BLE/FrameRouter.swift
@@ -1,5 +1,6 @@
import Foundation
import WhoopProtocol
+import WhoopStore
import StrandAnalytics
/// Pure decode→state router. Takes a COMPLETE (already reassembled) frame, decodes it with
@@ -9,7 +10,19 @@ public final class FrameRouter {
private let state: LiveState
/// Called when the strap pushes an EVENT packet (WHOOP's strap-as-clock catch-up signal). The
/// BLEManager wires this to a rate-limited requestSync(.strap). nil in pure/unit contexts.
+ /// #1193: the WHOOP 4.0 strap serial, decoded from the `GET_HELLO_HARVARD` (35) response. A 4.0 has
+ /// no DIS serial, so this is its only stable identity — see `Whoop4HelloSerial`. Fires on every hello;
+ /// the manager decides whether it is confirmed enough to adopt.
+ var onStrapSerial: ((String) -> Void)?
+
var onSyncTrigger: (() -> Void)?
+ /// #1706: which strap this connection is talking to, so an alarm readback can be attributed to a
+ /// device. Set per connection by BLEManager immediately AFTER `family`, whose didSet clears this —
+ /// a path that sets the family and forgets the id then attributes nothing rather than carrying the
+ /// previous connection's strap forward, which is the very mistake this attribution exists to stop.
+ /// nil in pure/unit contexts, which the verdict treats as unattributed rather than guessing.
+ var deviceId: String?
+
/// Which family's framing to decode with. Set per connection by BLEManager. WHOOP 5.0/MG frames
/// use the CRC16/offset-8 envelope; the biometric field decode for puffin is still a stub, so
/// WHOOP 5 custom frames currently surface only their envelope (live HR/battery come from the
@@ -18,9 +31,22 @@ public final class FrameRouter {
// #900: a fresh connection is a fresh capture session — re-arm the per-command raw-frame dump so
// each connect can re-capture the disputed COMMAND_RESPONSE prefix once. `family` is set fresh per
// connection by BLEManager (connectCore), so this is the per-session reset hook.
- didSet { rawDumpedRespCmds.removeAll(); loggedFirmwareGate = nil }
+ didSet {
+ rawDumpedRespCmds.removeAll(); loggedFirmwareGate = nil; deviceId = nil
+ rejectTally = FrameRejectTally(); loggedRejectReasons.removeAll()
+ }
}
+ /// Rejected frames on this connection, per reason, plus the one named counter for the class that
+ /// used to pass the gates (payload CRC32 verified, envelope not). Reset per connection alongside the
+ /// other per-connection routing state, since that is the unit the readout is about.
+ public private(set) var rejectTally = FrameRejectTally()
+
+ /// Reasons already reported on this connection, so the Test Centre line is one per REASON rather
+ /// than one per frame: a noisy link rejects continuously, and a per-frame line would bury the
+ /// transition that carries the information.
+ private var loggedRejectReasons: Set = []
+
/// #900: resp command names (e.g. "GET_BATTERY_LEVEL(26)") whose raw COMMAND_RESPONSE frame has already
/// been dumped this connection. The provenance dump fires once per command per session so a 4.0's
/// per-poll battery reads don't flood the strap log. Reset when `family` is set at connect.
@@ -51,9 +77,14 @@ public final class FrameRouter {
assert(parsed == parseFrame(frame, family: family),
"FrameRouter.handle: threaded ParsedFrame != fresh parse (#47 parse-once invariant)")
#endif
- guard parsed.ok else { return }
- // Reject frames that failed their checksum — never let bad bytes drive state.
- if parsed.crcOK == false { return }
+ // ONE gate, the verifier's FULL verdict: header checksum, payload CRC32 and structural length
+ // together. This used to be two steps — a parse-succeeded flag, then a separate payload-CRC
+ // check — and between them sat the class this change closes: a frame whose payload CRC32 is
+ // right while its header checksum or declared length is not. Never let bad bytes drive state.
+ guard parsed.ok else {
+ noteRejectedFrame(parsed)
+ return
+ }
// #987: stamp frame liveness for the Connection readout's "last frame" row. A plain (non-
// published, see LiveState) Int write, so the raw flood costs no re-renders here.
@@ -149,6 +180,32 @@ public final class FrameRouter {
let verdict = r == nil ? "no result byte" : (accepted ? "accepted" : "REJECTED")
state.append(log: "reboot: strap acked result=\(rhex) (\(verdict))")
}
+ // #1823: the clock exchange, on BOTH families. NOOP wrote "clock synced" the instant it queued
+ // the writes and never read the answer, so a strap log asserted the clock was set while the
+ // readout said 1970/71 — two contradictory lines with nothing to separate them. Same
+ // accept/reject shape REBOOT_STRAP already uses: the family's own result offset and polarity
+ // (5/MG 1=SUCCESS, 4.0 0=SUCCESS). LOG-ONLY; it never gates behaviour.
+ if let cmd = parsed.cmdName, cmd.hasPrefix("SET_CLOCK") || cmd.hasPrefix("GET_CLOCK") {
+ // NO accept/reject verdict here, on EITHER family, and that is deliberate.
+ //
+ // 4.0's 0=accepted is the reboot probe's own explicitly UNVERIFIED reading. And on 5/MG
+ // the result byte may not exist at all for this command: the captured-frame fixture builds
+ // a puffin COMMAND_RESPONSE as [36, seq, cmd] + payload at offset 8, so @11 is already
+ // PAYLOAD and the @12 that `commandResultByte` reads is a payload byte, not a result code.
+ // REBOOT_STRAP's use of it was validated against reboot's own frames; nothing establishes
+ // it for the clock.
+ //
+ // Inventing a verdict from that is precisely the fault this line was added to fix - the
+ // old "clock synced" log asserted an outcome nobody had checked. So quote the evidence
+ // and let a maintainer decode it: the byte at the family's result offset, and the WHOLE
+ // frame (#900's format), uncapped. A truncated clock frame answers nothing, and the full
+ // frame is what makes a wrong offset assumption visible instead of silently misleading.
+ let r = Self.commandResultByte(in: frame, family: family)
+ let rhex = r.map { String(format: "0x%02x", UInt8(truncatingIfNeeded: $0)) } ?? "none"
+ state.append(log: "clock: \(cmd) reply byte@resultOffset=\(rhex) "
+ + "frame=\(Self.fullFrameHex(frame))",
+ domain: .connection)
+ }
if family == .whoop4, let cmd = parsed.cmdName {
if cmd.hasPrefix("GET_ADVERTISING_NAME_HARVARD") {
if let name = Self.advertisingName(in: frame), !name.isEmpty {
@@ -175,15 +232,41 @@ public final class FrameRouter {
// #34: persist what the strap reports so the debug export can show sent-vs-reported.
let d = UserDefaults.standard
d.set(Int(epoch), forKey: "alarm.lastReportedEpoch")
+ // #1706: the strap this readback came from, and the bytes it came in. The raw
+ // frame is what separates a genuinely-stored stale alarm from a misdecode of a
+ // fixed response field, and the live log rolls long before a debug export is
+ // taken — a 2045 readback went unexplained for exactly that reason.
+ d.set(deviceId, forKey: "alarm.lastReportedDeviceId")
+ d.set(raw, forKey: "alarm.lastReportedRaw")
d.set(Date().timeIntervalSince1970, forKey: "alarm.lastReportedAt")
// #34: count CONSECUTIVE rejections (reported ≠ what we last sent) — the signature of
// a corrupted strap alarm register. A matching readback resets it, so a transient
// (first read stale, then correct) never trips the warning; only a persistent refusal
// climbs. SmartAlarmView surfaces the warning at ≥2; the debug export shows the count.
// Observability only — never gates the BLE arm.
+ // #1706: the streak raises a UI warning at two, so it must only COUNT a
+ // disagreement PROVEN to be the same strap. A cross-strap reading is evidence of
+ // nothing and leaves the streak alone — advancing would warn about a strap that
+ // was never asked, clearing would hide a real refusal. An unattributed one is a
+ // different case, handled below.
if let sent = d.object(forKey: "alarm.lastArmSentEpoch") as? Int {
- d.set(abs(Int(epoch) - sent) > 120 ? d.integer(forKey: "alarm.rejectStreak") + 1 : 0,
- forKey: "alarm.rejectStreak")
+ let verdict = AlarmReadback.verdict(
+ sentEpoch: sent,
+ reportedEpoch: Int(epoch),
+ sentDeviceId: d.string(forKey: "alarm.lastArmDeviceId"),
+ reportedDeviceId: deviceId) // the local, not a re-read of what we just wrote
+ if AlarmReadback.countsAsRejection(verdict) {
+ d.set(d.integer(forKey: "alarm.rejectStreak") + 1, forKey: "alarm.rejectStreak")
+ } else if AlarmReadback.clearsRejectionStreak(verdict) {
+ d.set(0, forKey: "alarm.rejectStreak")
+ } else if verdict == .unattributed {
+ // Any streak standing here was built by the cross-strap comparison this
+ // replaces, so it cannot be trusted — and since only a proven match clears
+ // the streak now, leaving it would hold SmartAlarmView's warning up forever
+ // on an install that upgraded mid-streak. Discard once; the next attributed
+ // readback rebuilds it honestly.
+ d.set(0, forKey: "alarm.rejectStreak")
+ }
}
} else if Self.readbackReportsNoAlarm(in: frame) {
// #34 (issue comment 2026-07-12): the strap's "nothing armed" sentinel — the epoch
@@ -209,7 +292,7 @@ public final class FrameRouter {
let r = Self.commandResultByte(in: frame)
let rhex = r.map { String(format: "0x%02x", UInt8(truncatingIfNeeded: $0)) } ?? "none"
state.append(log: "Alarm: strap answered the arm (SET_ALARM_TIME) with result=\(rhex) — log-only, 4.0 result-code meaning unverified")
- } else if cmd.hasPrefix("GET_HELLO_HARVARD"), TestCentre.active(.connection) {
+ } else if cmd.hasPrefix("GET_HELLO_HARVARD") {
// #1303: capture aid for WHOOP-4.0 stable-serial identity. The strap serial lives in this
// GET_HELLO_HARVARD (cmd 35) response. This used to dump the payload RAW, which answered
// the question — the serial is the 9-char alnum run at offset 14 — but a captured 4.0
@@ -225,10 +308,18 @@ public final class FrameRouter {
// the 5/MG device-name offset and means nothing in a cmd-35 payload — passing it would
// mislabel whatever run happened to start there. Log-only; decodes/persists nothing.
let helloPay = Self.commandResponsePayload(in: frame) ?? []
- state.append(log: HelloIdentityProbe.report(payload: helloPay,
- block: "HELLO_HARVARD(35)",
- knownNameOffset: -1)
- + " — locate the strap serial offset (#1303)")
+ // #1193: the identity read is UNGATED, unlike the probe below it. Adoption has to
+ // work for every 4.0 user, and Test Centre is off for almost all of them — gating it
+ // would ship a stable id only to the people already debugging. The decoder reads a
+ // fixed 9-byte window and can never reach the device key beside it, so nothing here
+ // widens what an ordinary session touches.
+ if let serial = Whoop4HelloSerial.decode(payload: helloPay) { onStrapSerial?(serial) }
+ if TestCentre.active(.connection) {
+ state.append(log: HelloIdentityProbe.report(payload: helloPay,
+ block: "HELLO_HARVARD(35)",
+ knownNameOffset: -1)
+ + " — locate the strap serial offset (#1303)")
+ }
}
}
// #1303: the 5/MG half of the same hunt. The 4.0 aid above is 4.0-only — correctly, since a
@@ -250,6 +341,31 @@ public final class FrameRouter {
let pay = Self.commandResponsePayload(in: frame, family: family) {
state.append(log: HelloIdentityProbe.report(payload: pay) + " — locate the strap serial (#1303)")
}
+ // The 5/MG battery pack (cmd 151). `BatteryPackInfo` has decoded this reply since its offsets
+ // were captured, and until now nothing sent the command — so the decoder had no caller and the
+ // offsets have never been seen against a live strap.
+ //
+ // LOG-ONLY, deliberately. Those offsets are an unvalidated candidate re-derived from two
+ // frames, and a wrong one does not fail: it renders a confident wrong number. So this reports
+ // what it read AND whether the reading passes the `displayable` sanity check, which is exactly
+ // the evidence needed before a card can honestly show it. Test Centre → Connection gated, so
+ // nothing here reaches a default (shareable) strap log. Persists nothing.
+ if family == .whoop5, let cmd = parsed.cmdName, cmd.hasPrefix("GET_BATTERY_PACK_INFO("),
+ TestCentre.active(.connection) {
+ if let info = BatteryPackInfo.decode(frame: frame) {
+ let soc = info.socPct.map { String(format: "%.1f%%", $0) } ?? "—"
+ // logSafe, NOT the raw serial. `redactPii` cannot catch this one — its rules key on a
+ // literal "WHOOP " prefix or a `whoop-` id, and a bare `serial=BB5AP…` matches neither —
+ // so the redaction that protects the strap's serial would have let the pack's through to
+ // an exportable log. Three characters is enough to tell two packs apart, which is all a
+ // diagnostic needs.
+ state.append(log: "[pack] present=\(info.present) soc=\(soc) "
+ + "serial=\(WhoopSerialIdentity.logSafe(serial: info.serial)) "
+ + "displayable=\(info.displayable) (#1303)")
+ } else {
+ state.append(log: "[pack] cmd 151 replied but did not decode — offsets may have moved")
+ }
+ }
// #900: surface a non-SUCCESS COMMAND_RESPONSE on BOTH families (a result=UNSUPPORTED here is how
// the MG haptics rejection #48 would show), and — the key part — annotate a reply that DELIVERED
// ITS VALUE rather than reporting a bare failure. The 4.0 GET_BATTERY_LEVEL replies on record carry
@@ -289,6 +405,20 @@ public final class FrameRouter {
}
}
+ case "CONSOLE_LOGS":
+ // The 5/MG strap narrates its own sync engine here — "BLE: PullStats: Data: N, Events: N…",
+ // "History burst success. Trim: 0x…", "Historical Dump Complete". Android has mirrored this
+ // into the strap log since #78 and calls it gold for protocol research; this side decoded the
+ // text and then dropped it on the floor, so an Apple strap log has never carried a word of it.
+ //
+ // It is worth more than curiosity. `PullStats: Data: 0` is the STRAP stating it sent no
+ // records, which is a far stronger answer to a "synced but no data" report (#1683) than NOOP
+ // inferring emptiness from its own decode — the difference between the strap saying nothing
+ // was there and us saying we found nothing.
+ //
+ // Capped at 300 characters to match the Kotlin twin exactly; the ring buffer holds 2k lines.
+ appendStrapConsole(parsed)
+
case "EVENT":
if let ev = parsed.parsed["event"]?.stringValue {
// #92: don't surface the live-HR stream toggle (BLE_REALTIME_HR_ON/OFF) in "Last
@@ -334,6 +464,22 @@ public final class FrameRouter {
} else if ev.hasPrefix("CHARGING_OFF") {
state.charging = false
}
+ // #1826: BATTERY_PACK_CONNECTED(21) / BATTERY_PACK_REMOVED(22), declared in the shared
+ // schema and handled on neither platform until @Zebsi235 measured them. On a 5/MG they
+ // fire on every attach and detach and LEAD the 7/8 edges above, so the pill responds when
+ // a pack goes on instead of waiting to catch a later edge. A WHOOP 4.0 never sends them.
+ //
+ // NO replay guard here, unlike the Kotlin twin. That is deliberate and not an omission:
+ // this router is live-only — the Backfiller holds no reference to it, so a replayed
+ // offload event never reaches this code, which is the same reason the CHARGING_ON/OFF
+ // branch above carries none. Android's EVENT routing does see replays, and its capture
+ // showed the strap re-sending these edges with byte-identical payloads, so the gate is
+ // load-bearing THERE. Copying it here would guard against something that cannot happen.
+ if ev.hasPrefix("BATTERY_PACK_CONNECTED") {
+ state.charging = true
+ } else if ev.hasPrefix("BATTERY_PACK_REMOVED") {
+ state.charging = false
+ }
// Physical inputs the strap exposes — live only (this path never sees historical
// replay, which goes through the Backfiller). Event strings are "NAME(rawValue)".
if ev.hasPrefix("DOUBLE_TAP") {
@@ -415,8 +561,14 @@ public final class FrameRouter {
/// Space-separated lowercase hex of a COMMAND_RESPONSE payload, for the raw-hex diagnostic fallback
/// when a readback payload doesn't decode. nil when the frame carries no payload.
- nonisolated static func commandResponsePayloadHex(in frame: [UInt8]) -> String? {
- guard let payload = commandResponsePayload(in: frame), !payload.isEmpty else { return nil }
+ /// #1823: takes `family` because `commandResponsePayload` slices at a family-specific inner offset
+ /// (5/MG 8, 4.0 its own). This wrapper used to drop the argument and always slice at the 4.0 offset,
+ /// so a 5/MG payload came back shifted - the same fixed-offset mistake the REBOOT_STRAP comment
+ /// records, and it would have mis-read the clock payload on the family the clock diagnostic is for.
+ /// Defaulted to `.whoop4` so the existing WHOOP4-gated alarm caller is unchanged.
+ nonisolated static func commandResponsePayloadHex(in frame: [UInt8],
+ family: DeviceFamily = .whoop4) -> String? {
+ guard let payload = commandResponsePayload(in: frame, family: family), !payload.isEmpty else { return nil }
return payload.map { String(format: "%02x", $0) }.joined(separator: " ")
}
@@ -437,10 +589,12 @@ public final class FrameRouter {
}
/// Extract the armed-alarm epoch from a GET_ALARM_TIME (cmd 67) COMMAND_RESPONSE, defensively.
- /// The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the two shapes the firmware could
- /// plausibly answer with - the SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the
- /// 9-byte payload we arm with) first, then a bare leading u32 LE - and accepts a candidate only when
- /// it passes `isPlausibleAlarmEpoch`. Anything else returns nil and the caller logs raw hex instead.
+ /// The WHOOP 4.0 response layout is UNDOCUMENTED, so this tries the shapes the firmware has been
+ /// seen to answer with - the 11-byte GET readback captured on fw 41.17.6.0
+ /// (`[form 0x01][stored flag][u32 LE epoch][00 00][04 00 20]`, epoch at offset 2) first, then the
+ /// SET_ALARM_TIME mirror (`[form 0x01][u32 LE epoch]…`, matching the 9-byte payload we arm with),
+ /// then a bare leading u32 LE - and accepts a candidate only when it passes
+ /// `isPlausibleAlarmEpoch`. Anything else returns nil and the caller logs raw hex instead.
/// Pure and CoreBluetooth-free so golden tests pin it (AlarmReadbackDecodeTests).
nonisolated static func armedAlarmEpoch(in frame: [UInt8]) -> UInt32? {
guard let payload = commandResponsePayload(in: frame) else { return nil }
@@ -451,14 +605,29 @@ public final class FrameRouter {
| (UInt32(payload[i + 2]) << 16)
| (UInt32(payload[i + 3]) << 24)
}
+ // The GET readback (fw 41.17.6.0, three arm/readback captures 2026-08-26..28, #34/#1706): the
+ // epoch sits ONE byte further than in the SET mirror, because the readback carries a stored
+ // flag (0x00 = nothing stored, 0x01 = stored) the arm payload does not. The mirror-offset read
+ // of this shape returns the epoch's LOW THREE bytes shifted up a byte, plus the flag — wrong
+ // by roughly 256x and free to land anywhere in u32 range. In all three captures it landed on
+ // a 2045 date INSIDE the 2017..2100 plausibility window (an arm for 2026-08-26 read back as
+ // 2045-09-24), so the gate did not catch it and a MISMATCH was counted against a strap whose
+ // register is fine. So on this shape the mirror offsets are known-wrong and must NOT be tried:
+ // offset 2 decodes, or the payload falls to the raw-hex line.
+ if payload.count == 11, payload.first == 0x01 {
+ if let e = u32le(at: 2), isPlausibleAlarmEpoch(e) { return e }
+ return nil
+ }
if payload.first == 0x01, let e = u32le(at: 1), isPlausibleAlarmEpoch(e) { return e }
if let e = u32le(at: 0), isPlausibleAlarmEpoch(e) { return e }
return nil
}
/// True when a GET_ALARM_TIME readback explicitly reports NO alarm stored — the epoch field decodes
- /// to 0 in the same shapes `armedAlarmEpoch` reads (SET-mirror `[0x01][u32=0]` first, then a bare
- /// leading `u32=0`). This is the strap's "nothing armed" sentinel, distinct from a genuinely
+ /// to 0 in the same shapes `armedAlarmEpoch` reads (the 11-byte GET readback `[0x01][flag][u32=0]…`
+ /// first — the #34 field-report payload `01 00 00 00 00 00 00 00 04 00 20` is exactly this shape
+ /// with the stored flag 0x00 — then the SET-mirror `[0x01][u32=0]`, then a bare leading `u32=0`).
+ /// This is the strap's "nothing armed" sentinel, distinct from a genuinely
/// unparseable payload: an arm the strap silently dropped reads back as epoch 0, so labelling it
/// "unrecognised" hid the real signal (#34). Only consulted AFTER `armedAlarmEpoch` returns nil, so a
/// plausible armed epoch never reaches here. Pure/CoreBluetooth-free so AlarmReadbackDecodeTests pin it.
@@ -471,6 +640,7 @@ public final class FrameRouter {
| (UInt32(payload[i + 2]) << 16)
| (UInt32(payload[i + 3]) << 24)
}
+ if payload.count == 11, payload.first == 0x01, let e = u32le(at: 2) { return e == 0 }
if payload.first == 0x01, let e = u32le(at: 1) { return e == 0 }
if let e = u32le(at: 0) { return e == 0 }
return false
@@ -509,6 +679,94 @@ public final class FrameRouter {
/// RTC (fix #72) — a live gesture is ~now in the strap's clock, a historical replay is old in it.
/// Deliberately does NOT touch lastEvent / sync trigger / bonded / battery — those stay on the normal
/// handle(frame:) path, so backfill UI behaviour is otherwise unchanged.
+ /// Mirror a CONSOLE_LOGS frame's text even during a backfill.
+ ///
+ /// The strap narrates its sync engine EXACTLY while offloading — "BLE: PullStats: Data: N",
+ /// "History burst success. Trim: 0x…", "Historical Dump Complete" — and offload frames are routed
+ /// straight to the Backfiller, bypassing `handle` entirely. So the `case "CONSOLE_LOGS"` there only
+ /// ever sees the rare console frame that arrives outside a sync, which is not the one worth having.
+ /// This is the same carve-out `dispatchLiveGestureIfFresh` makes for a live gesture mid-offload.
+ ///
+ /// Same cheap pre-check as that method: a single type-byte compare skips the CRC + FieldBuilder
+ /// decode for the thousands of type-47 records a sync produces, so the cost on the offload path is a
+ /// byte compare per frame. Family-aware (WHOOP4 type @[4], 5/MG @[8]).
+ func mirrorStrapConsoleIfPresent(frame: [UInt8]) {
+ guard frameTypeName(frame, family: family) == "CONSOLE_LOGS" else { return }
+ let parsed = parseFrame(frame, family: family)
+ // The full verdict, in one step — the frame is parsed right here, so `ok` already covers the
+ // payload CRC32 the second condition used to check separately.
+ guard parsed.ok else { return }
+ appendStrapConsole(parsed)
+ }
+
+ /// Count one rejected frame and say something about it exactly once (D3).
+ ///
+ /// Two different visibility rules, on purpose:
+ ///
+ /// - The class where the payload CRC32 VERIFIED while the envelope did not is announced always-on,
+ /// at its first sighting. It is the class that passed every gate before this change, it is what
+ /// the hardware run's abort criterion reads, and it costs nothing on a link where it never
+ /// happens — which is the whole point of leaving rare-event evidence unconditional.
+ /// - The ordinary per-connection detail sits behind the Test Centre's Connection domain, one line
+ /// per REASON. A resync after a lost notification rejects frames routinely and always has; a line
+ /// per frame would be noise, and the general per-reason counter is explicitly NOT the abort signal.
+ ///
+ /// Each line reports only what the parse result observed: the reason the verifier gave, and the
+ /// packet type the decoder actually read (a rejected frame keeps it).
+ private func noteRejectedFrame(_ parsed: ParsedFrame) {
+ let hadAdmittedClass = rejectTally.payloadCRCOKButEnvelopeRejected > 0
+ let reason = rejectTally.note(parsed)
+ if !hadAdmittedClass && rejectTally.payloadCRCOKButEnvelopeRejected > 0 {
+ state.append(log: "Frame rejected while its payload CRC32 verified "
+ + "(reason=\(reason.rawValue), type=\(parsed.typeName), \(parsed.lenBytes) bytes) "
+ + "— the frame class that reached live state before the integrity gate.")
+ }
+ if TestCentre.active(.connection), loggedRejectReasons.insert(reason).inserted {
+ state.append(log: "frameReject reason=\(reason.rawValue) type=\(parsed.typeName) "
+ + "bytes=\(parsed.lenBytes)", domain: .connection)
+ }
+ }
+
+ /// Count an OFFLOAD frame's verdict on this connection (D3).
+ ///
+ /// The BLE seam routes a replayed history frame straight to the Backfiller, so `handle(parsed:frame:)`
+ /// — the only caller of `noteRejectedFrame` — never sees it. Without this the tally, and with it the
+ /// ONE counter the hardware run's abort criterion is read from, stays at zero during exactly the
+ /// traffic in which a wrongly-emptied and then acked section is a permanent loss. The verdict is
+ /// formed once at the seam and handed over here; nothing is parsed twice.
+ ///
+ /// The line reports only what the verifier observed. There is no packet type in it because nothing
+ /// decoded one on this path — naming the offload is what this evidence can attribute.
+ func noteOffloadFrameVerdict(_ check: FrameCheck) {
+ let hadAdmittedClass = rejectTally.payloadCRCOKButEnvelopeRejected > 0
+ let reason = rejectTally.note(check)
+ guard reason != .none else { return }
+ if !hadAdmittedClass && rejectTally.payloadCRCOKButEnvelopeRejected > 0 {
+ state.append(log: "Frame rejected while its payload CRC32 verified "
+ + "(reason=\(reason.rawValue), during a history offload) "
+ + "— the frame class that reached live state before the integrity gate.")
+ }
+ if TestCentre.active(.connection), loggedRejectReasons.insert(reason).inserted {
+ state.append(log: "frameReject reason=\(reason.rawValue) offload=true", domain: .connection)
+ }
+ }
+
+ /// Fold the reassembler's monotonic below-minimum drop count into this connection's tally, so a byte
+ /// run dropped before any parser saw it still shows up as a rejection with the reason
+ /// "belowMinimumLength" instead of vanishing. Idempotent — only the growth since the last call is
+ /// added, so the BLE seam can call it after every notification.
+ func noteReassemblerDrops(_ monotonicTotal: Int) {
+ rejectTally.absorbReassemblerDrops(monotonicTotal)
+ }
+
+ /// The one place the strap's own narration reaches the log, so the live and offload paths cannot
+ /// drift in what they emit. Capped at 300 characters to match the Kotlin twin exactly.
+ private func appendStrapConsole(_ parsed: ParsedFrame) {
+ guard parsed.typeName == "CONSOLE_LOGS",
+ let txt = parsed.parsed["log"]?.stringValue, !txt.isEmpty else { return }
+ state.append(log: "strap: \(String(txt.prefix(300)))")
+ }
+
func dispatchLiveGestureIfFresh(frame: [UInt8], now: Int = Int(Date().timeIntervalSince1970)) {
// #47: this fires for EVERY frame on the OFFLOAD path (thousands of type-47 records over a
// multi-minute sync) purely to catch a rare EVENT gesture. Cheap type-only pre-check skips the full
@@ -517,7 +775,9 @@ public final class FrameRouter {
// anyway. Family-aware (WHOOP4 type @[4], 5/MG @[8]).
guard frameTypeName(frame, family: family) == "EVENT" else { return }
let parsed = parseFrame(frame, family: family)
- guard parsed.ok, parsed.crcOK != false else { return }
+ // Same single gate as `handle`: a gesture changes worn state, so it may only come from an
+ // intact frame. `frameTypeName` is a type PEEK and says nothing about integrity.
+ guard parsed.ok else { return }
guard parsed.typeName == "EVENT", let ev = parsed.parsed["event"]?.stringValue else { return }
guard let ts = parsed.parsed["event_timestamp"]?.intValue, ts > 0 else { return } // fail closed
guard abs(now - ts) <= FrameRouter.liveGestureWindowSeconds else { return }
diff --git a/Strand/BLE/HelloSuppression.swift b/Strand/BLE/HelloSuppression.swift
new file mode 100644
index 0000000000..0e1dd4b1d2
--- /dev/null
+++ b/Strand/BLE/HelloSuppression.swift
@@ -0,0 +1,156 @@
+import Foundation
+import WhoopProtocol
+
+/// Whether to send the WHOOP 5/MG CLIENT_HELLO at all on this connect.
+///
+/// The #1635 field captures show the hello is what ends the link. Across two sessions and sixteen writes it
+/// was never once acknowledged, and the drop is locked to the HELLO rather than to the connect: 3.158s,
+/// 3.159s, 3.155s after the write, cycle after cycle, while live HR streams happily over the standard
+/// profile the whole time.
+///
+/// An HCI capture later established the cause: the phone transmits an SMP Pairing Request and the strap
+/// answers `Pairing Not Supported` (0x05). The refusal is categorical — the encrypted bond this hello waits
+/// behind can never arrive on a 5/MG — so the write is a handshake that has never once succeeded knocking a
+/// strap that CAN deliver live HR off every few seconds.
+///
+/// Suppressing it after the give-up latches trades an unreachable capability (puffin commands, history
+/// offload) for one that demonstrably works (continuous live HR) — the "Live HR, not fully paired" state
+/// the app already models (#69), reached deliberately instead of never at all.
+///
+/// [userInitiated] always re-attempts: suppression is a fallback for automatic reconnects, never a
+/// permanent verdict. Someone who puts the strap in pairing mode and presses Connect must get a fresh try.
+/// That try is ONE connect's worth and nothing more — the latch itself survives the tap, so if the strap
+/// still will not answer, the automatic reconnects behind it stay suppressed instead of re-earning the
+/// give-up over five more refusals. The latch is dropped by a genuine bond (it demonstrably works now) or
+/// by forgetting the strap, which are the two events that are actually evidence about this device.
+///
+/// Kotlin twin: `com.noop.ble.shouldSendClientHello`.
+func shouldSendClientHello(suppressedForDevice: Bool, userInitiated: Bool) -> Bool {
+ !suppressedForDevice || userInitiated
+}
+
+/// How many consecutive refusals this cause needs before the give-up latches.
+///
+/// The flat 5 was argued for the AUTH-REFUSAL branch and only makes sense there: the pairing hint shows at
+/// 2, the hint asks the user to do something (close the official app, free a stale phone pairing), and the
+/// extra cycles are the time to do it before NOOP stops hammering.
+///
+/// An unanswered handshake gives the user nothing to act on. The write vanishes, the strap is not refusing
+/// anything it could be talked out of, and the outcome — suppress the hello and keep streaming live HR —
+/// needs no permission and costs no capability that was reachable anyway. Every cycle spent waiting is a
+/// ~4.8s link drop bought for nothing, so this branch stops at 3.
+///
+/// Not 2, which is exactly where the pairing hint fires: a strap whose hello is unanswered twice by some
+/// transient would latch a PERSISTED verdict with no margin at all. 3 keeps one cycle of margin and still
+/// takes roughly 40% off the churn.
+///
+/// Kotlin twin: `com.noop.ble.UNANSWERED_GIVE_UP_THRESHOLD`.
+let unansweredGiveUpThreshold = 3
+
+/// The give-up threshold for the refusal in hand. Keyed on the same `authRefusal` that decides whether the
+/// give-up suppresses or pauses (`giveUpSuppressesHello`), so the two can never disagree about which branch
+/// a refusal belongs to.
+///
+/// Kotlin twin: `com.noop.ble.giveUpThresholdFor`.
+func giveUpThresholdFor(authRefusal: Bool, pauseThreshold: Int) -> Int {
+ authRefusal ? pauseThreshold : unansweredGiveUpThreshold
+}
+
+/// Should the give-up latch suppress the hello, rather than pause auto-reconnect?
+///
+/// The two give-up causes want opposite treatment, which is why they are split here rather than sharing
+/// one branch:
+///
+/// - An AUTH REFUSAL means the strap actively declined the encrypted bond — typically because it is still
+/// held by the official WHOOP app. Reconnecting cannot help until the user acts, so pausing is right.
+/// - An UNANSWERED HANDSHAKE means the write vanished. The link itself is healthy and streaming, so
+/// pausing throws away working live HR to punish a handshake nobody is waiting on. Suppress the hello
+/// and stay connected instead.
+///
+/// Splitting them is the whole point: before this, both ended in the same pause, so the strap that could
+/// still deliver HR was treated exactly like the one that could not.
+///
+/// Kotlin twin: `com.noop.ble.giveUpSuppressesHello`.
+func giveUpSuppressesHello(authRefusal: Bool) -> Bool { !authRefusal }
+
+/// Does this disconnect count as the strap refusing to bond?
+///
+/// Two observations mean the same thing for the purpose of giving up: the strap answered the bond write
+/// with INSUFFICIENT_AUTHENTICATION / INSUFFICIENT_ENCRYPTION, or it never answered the CLIENT_HELLO at
+/// all. The second was invisible to the give-up on this platform, because the gate tested only the write
+/// error — and an unanswered handshake produces no write error at all, just a link that drops a few
+/// seconds later. The consequence is the loop #1635 describes, with nothing able to end it.
+///
+/// Deliberately does NOT widen what counts as an AUTH refusal — [helloUnacked] is a separate signal carried
+/// separately, so the user-facing guidance can stay honest about which was observed. An auth refusal
+/// supports naming a cause; an unanswered handshake does not.
+///
+/// Pure so the rule is unit-tested without a radio. Kotlin twin: `com.noop.ble.countsAsBondRefusal`.
+func countsAsBondRefusal(
+ isAuthRefusalStatus: Bool,
+ helloUnacked: Bool,
+ alreadyBonded: Bool,
+ family: DeviceFamily
+) -> Bool {
+ if alreadyBonded { return false } // bonded already — not a pairing problem
+ if family != .whoop5 { return false } // the 4.0 bonds cleanly; this is the 5/MG handshake
+ return isAuthRefusalStatus || helloUnacked
+}
+
+/// UserDefaults key holding the hello-suppression latch for one strap.
+///
+/// Per device, and lowercased for the same reason the firmware key is: the same strap can present its
+/// identifier in different cases across sessions, and a case-sensitive key would silently latch a second
+/// time under a second key instead of reading the first.
+///
+/// DIVERGENCE FROM ANDROID (deliberate, PII): the identifier here is the CoreBluetooth-local peripheral
+/// UUID, which is per-install rather than the hardware address. The key SHAPE is shared with the Kotlin
+/// twin so the two are recognisably the same latch; the value that goes into it is not the same kind of
+/// identifier, and deliberately so.
+///
+/// Kotlin twin: `com.noop.ble.helloSuppressionPrefKey`.
+func helloSuppressionPrefKey(_ peripheralId: String?) -> String? {
+ guard let raw = peripheralId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
+ return nil
+ }
+ return "noop.helloUnanswered.\(raw.lowercased())"
+}
+
+/// Read/write the per-strap hello-suppression latch. Kept beside the key helper so no caller hand-rolls
+/// the key, which is how the Android side ended up with a case-sensitivity bug worth a comment.
+enum HelloSuppressionStore {
+ static func suppressed(_ peripheralId: String?) -> Bool {
+ guard let key = helloSuppressionPrefKey(peripheralId) else { return false }
+ return UserDefaults.standard.bool(forKey: key)
+ }
+
+ static func setSuppressed(_ peripheralId: String?, _ suppressed: Bool) {
+ guard let key = helloSuppressionPrefKey(peripheralId) else { return }
+ if suppressed {
+ UserDefaults.standard.set(true, forKey: key)
+ } else {
+ // Remove rather than store false: an absent key and a false one mean the same thing, and this
+ // keeps a strap that never latched from leaving a permanent entry behind.
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+ }
+}
+
+/// May the keep-alive timer do its work on this link?
+///
+/// #1635: the timer used to gate on the genuine encrypted bond alone. A SUPPRESSED 5/MG never reaches
+/// one, so that gate switched the whole timer off for exactly the link the suppression keeps up for
+/// hours — taking the liveness watchdog with it and leaving a stalled stream with nothing to bounce it.
+/// The stable-live-HR promise would have quietly depended on a stream nothing was watching.
+///
+/// So a 5/MG that is STREAMING (`bonded` is the live-HR shortcut of #8/#69, set when HR actually arrives
+/// over the standard profile) is admitted too. Deliberately narrower than the Android twin, which admits
+/// any `bonded` link: here it is 5/MG only, and only as far as the watchdog — the caller re-guards on the
+/// real bond before anything puffin-framed, which an unbonded link cannot land anyway.
+///
+/// Pure so the rule is pinned without a radio.
+func keepAliveMayRun(connected: Bool, didBond: Bool, bonded: Bool, family: DeviceFamily) -> Bool {
+ guard connected else { return false }
+ if didBond { return true }
+ return bonded && family == .whoop5
+}
diff --git a/Strand/BLE/HuamiHRSource.swift b/Strand/BLE/HuamiHRSource.swift
index 4e54de5411..eae770dd40 100644
--- a/Strand/BLE/HuamiHRSource.swift
+++ b/Strand/BLE/HuamiHRSource.swift
@@ -230,7 +230,7 @@ extension HuamiHRSource: @preconcurrency CBCentralManagerDelegate {
let id = peripheral.identifier
let firstSight = seenPeripherals[id] == nil
seenPeripherals[id] = peripheral
- if firstSight { log("Huami: found \(name) (\(id)) rssi \(RSSI.intValue)") }
+ if firstSight { log("Huami: found \(LiveState.logSafeDeviceName(name)) (\(id)) rssi \(RSSI.intValue)") }
let dev = DiscoveredDevice(id: id, name: name.isEmpty ? brand.displayBrand : name, rssi: RSSI.intValue)
if let idx = discovered.firstIndex(where: { $0.id == id }) {
discovered[idx] = dev
diff --git a/Strand/BLE/LastSyncAttribution.swift b/Strand/BLE/LastSyncAttribution.swift
new file mode 100644
index 0000000000..9af8316d2a
--- /dev/null
+++ b/Strand/BLE/LastSyncAttribution.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+/// Which "last sync" timestamp belongs to a strap, given what is known about it.
+///
+/// The same defect `FirmwareAttribution` fixed for firmware strings, in the same file's other line.
+/// `lastSyncedAt` is ONE defaults key, stamped on any strap's HISTORY_COMPLETE and read back for
+/// whichever strap is active. On a single-strap install those are the same strap, which is why it went
+/// unnoticed. With two paired it reports the other one's: an Android capture showed "Last sync: 4d ago"
+/// beside zero banked rows for the active 5/MG, the timestamp belonging to a 4.0 last worn three days
+/// earlier.
+///
+/// Worse than merely wrong: it is wrong in the reassuring direction. A strap that has never synced reads
+/// as recently synced, and the pull-to-refresh that cannot possibly change it looks broken rather than
+/// inapplicable. On Android that sent an investigation after a sync regression that never existed.
+///
+/// The rule, in order:
+/// - `perDevice` wins: this strap's own stamp, from its own completed offload.
+/// - `legacyGlobal` ONLY when `pairedCount` is 1. The old key cannot say which strap it belongs to, so
+/// it is trustworthy exactly when there is only one strap it could have come from — which keeps a
+/// single-strap install reading correctly across the upgrade instead of resetting to "never".
+/// - otherwise nil, meaning "this strap has never synced". For a strap in that state it is not a
+/// degraded answer, it is the correct one.
+///
+/// Deliberately NOT migrated by writing the global onto the active device at upgrade: that is the same
+/// guess in a different place, and on a multi-strap install it would bake the wrong attribution in
+/// permanently instead of leaving it to self-correct at the next real sync.
+///
+/// Pure so the attribution is unit-tested without defaults, a strap, or a registry. Kotlin twin:
+/// `com.noop.ble.resolveLastSync`.
+enum LastSyncAttribution {
+
+ static func resolve(perDevice: Double?,
+ legacyGlobal: Double?,
+ pairedCount: Int) -> Double? {
+ if let perDevice, perDevice > 0 { return perDevice }
+ if let legacyGlobal, legacyGlobal > 0, pairedCount == 1 { return legacyGlobal }
+ return nil
+ }
+
+ /// The per-device defaults key for a persisted last-sync timestamp.
+ ///
+ /// Keyed on the BLE peripheral identifier for the same reason `FirmwareAttribution.prefKey` is:
+ /// HISTORY_COMPLETE arrives on a connection whose peripheral is known, and resolving it to a registry
+ /// id there would repeat the mis-mapping #1527 fixed for `lastSeen` — stamping "the active row"
+ /// records a sync for a strap that was never connected, which is the class of error being removed.
+ ///
+ /// Lowercased, because the same strap can present its identifier in different cases across sessions
+ /// and a case-sensitive key would strand the earlier stamp under a second name. Returns nil for a
+ /// blank identifier, so a caller with none writes nothing rather than writing to a key that belongs
+ /// to no device.
+ static func prefKey(peripheralId: String?) -> String? {
+ guard let p = peripheralId?.trimmingCharacters(in: .whitespaces), !p.isEmpty else { return nil }
+ return "noop.lastSyncAt.\(p.lowercased())"
+ }
+
+ /// The per-device defaults key for the #57 write-health stamps ("rows last landed", "offload stalled").
+ ///
+ /// Same defect as `prefKey` and found in the same capture, one line below it: "Data write: rows last
+ /// landed 4d ago" printed against a strap whose own row count was zero, because the stamp was global
+ /// and the other strap had written it.
+ ///
+ /// `kind` separates "ok" from "stalled" so both keep the per-device scoping rather than only the one
+ /// that happened to be noticed — they are read as a PAIR ("stalled more recently than ok" is the
+ /// alarm), and scoping one without the other would compare a strap's own stall against another
+ /// strap's success. Kotlin twin: `com.noop.ble.writeHealthPrefKey`.
+ static func writeHealthPrefKey(peripheralId: String?, kind: String) -> String? {
+ guard let p = peripheralId?.trimmingCharacters(in: .whitespaces), !p.isEmpty else { return nil }
+ return "sync.\(kind).\(p.lowercased())"
+ }
+}
diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift
index bb324fc052..9eb0727585 100644
--- a/Strand/BLE/LiveState.swift
+++ b/Strand/BLE/LiveState.swift
@@ -24,6 +24,14 @@ public final class LiveState: ObservableObject {
/// connect/disconnect. Drives the Live pill's two-state distinction; the encrypted channel (buzz,
/// alarm, double-tap, history offload) only works when this is true.
@Published public var encryptedBond: Bool = false
+ /// True once this strap can actually hand over history — the UI mirror of `BLEManager`'s
+ /// `connectHandshakeDone`, which `beginBackfill` already requires before it will request an offload.
+ ///
+ /// Exposed because `bonded` is NOT that condition and reads true too early: the live-HR path sets it
+ /// for a 5/MG that has never completed a handshake, so the sync controls were offered, accepted, and
+ /// then refused deeper down in silence. Gating on this makes them unavailable exactly when the sync
+ /// would have been declined anyway — never when it would have run. Kotlin twin: `LiveState.historyReady`.
+ @Published public var historyReady: Bool = false
/// #34: bumped by BLEManager once a WHOOP 4.0 connection has BOTH run its connect handshake (hello +
/// SET_CLOCK, exactly once — `connectHandshakeDone`) AND had the cmd-notify characteristic confirm
/// subscribed (`didUpdateNotificationStateFor` for it fired with `isNotifying == true`) — whichever of
@@ -68,10 +76,38 @@ public final class LiveState: ObservableObject {
/// GET_EXTENDED_BATTERY_INFO response (#592). Shown on the Devices card as a "x.xx V" readout beside the
/// percent; nil until the first battery event lands. Twin of the Android LiveState.batteryMv.
@Published public var batteryMv: Int? = nil
- /// Charging flag from the strap's BATTERY_LEVEL events — wire observation: u8 bit0 in the
- /// event payload (4.0 @26 / 5.0 @30), pushed ~every 8 min on captured links. nil until the
- /// first event of a session; cleared on disconnect so a stale flag can't outlive the link.
- /// Flag ONLY — the battery % keeps its family-specific source (#77).
+ /// Charging flag. Two sources, and they mean different things (#1935).
+ ///
+ /// The authority is the strap's BATTERY_LEVEL event — wire observation: u8 bit0 in the payload
+ /// (4.0 @26 / 5.0 @30), pushed ~every 8 min on captured links. That is a LEVEL signal from the
+ /// strap's own gauge: every live battery event rewrites this flag, whatever it was.
+ ///
+ /// On a 5/MG it is ALSO set by `BATTERY_PACK_CONNECTED(21)` and cleared by
+ /// `BATTERY_PACK_REMOVED(22)`, which is a latency win — 21 leads `CHARGING_ON(7)` by up to ~17 s in
+ /// captures, so the pill responds when the pack goes on. But 21 means A PACK WAS ATTACHED, not that
+ /// charging began. They diverge on a depleted pack or a poor contact: 21 fires, 7 never does, and
+ /// this reads true while nothing charges.
+ ///
+ /// THAT STATE IS BOUNDED, which is why it is documented rather than split. It does not last until 22:
+ /// the next live BATTERY_LEVEL overwrites it from the strap's own GAUGE, so the window is about one
+ /// battery cadence, and the gauge always gets the last word.
+ ///
+ /// It gets the last word only because nothing else repeats, which is a real constraint rather than an
+ /// observation (#1935). The pack record reaches this platform LOG-ONLY (`FrameRouter`, Test Centre
+ /// gated) and writes nothing here. Android learned the same rule the hard way: its pushed pack-info
+ /// event (109) once wrote `charging = true` on pack PRESENCE every couple of minutes, outran the
+ /// gauge, and held a flat pack at "charging" for its whole attachment. An EDGE may set this flag
+ /// (7, 21, 22); a repeating presence signal must not, or the gauge cannot correct it.
+ ///
+ /// It matters beyond the pill. `BLEManager.lowPowerThrottleActive` reads it and gates THREE levers, not
+ /// one: the low-battery offload cadence, the connection-priority throttle, and the continuous-capture
+ /// pause behind the user's own "Pause HRV capture" percentage. So a pack attached but not charging can
+ /// keep background capture running at low battery after the user asked for it to stop, for that
+ /// window. `BLEManager.batteryPollDue` also reads it, polling every tick instead of every
+ /// other, which is harmless and arguably wanted with a pack on.
+ ///
+ /// nil until the first event of a session; cleared on disconnect so a stale flag can't outlive the
+ /// link. Flag ONLY — the battery % keeps its family-specific source (#77).
@Published public var charging: Bool? = nil
/// The Oura ring's current wear/charge state (nil for non-Oura straps or before any evidence this
@@ -410,6 +446,13 @@ public final class LiveState: ObservableObject {
/// True while a historical offload session is running, so screens can say "Syncing strap
/// history…" instead of presenting half-loaded data as final (#77).
@Published public var backfilling = false
+ /// #1164 — true when the strap reports banked records newer than our local HR frontier (the strap has
+ /// data we haven't ingested yet), even when no offload is actively running. Set by BLEManager from the
+ /// GET_DATA_RANGE newest vs. the collector's latest HR sample, with the same 5-min `behindGapSeconds`
+ /// the auto-continue predicate uses. Cleared on disconnect so a stale "pending" can't outlive the link.
+ /// Drives the Today Rest "Pending sync" state so a provisional score isn't shown as final before the
+ /// full night is offloaded. Twin of the Android LiveState.historyPendingSync.
+ @Published public var historyPendingSync = false
/// Chunks acked during the current offload session — an honest progress signal (total pending is
/// unknowable from the protocol, so a count, never a percent).
@Published public var syncChunksThisSession: Int = 0
@@ -765,17 +808,176 @@ public final class LiveState: ObservableObject {
/// service base (…-8d6d-82b8-614a-1c8cb0f8dcc6) — those are public, identical on every strap, and
/// are exactly the GATT diagnostics a shared log needs to be useful (#421). Thanks @ujix (#447) for
/// catching the peripheral-UUID leak; this is a targeted form so we don't redact the service UUIDs.
+ /// #1833: mask a WHOOP serial that arrives as HEX rather than as text.
+ ///
+ /// Every rule in `redactPii` matches an identifier written as characters. None can see one encoded
+ /// as hex, because they are reading hex digits and not the ASCII those bytes decode to. On a 5/MG,
+ /// event 109 carries the strap serial as plain ASCII inside its payload, so any diagnostic that dumps
+ /// a frame or payload puts the serial into the log we ask people to attach to public issues — while
+ /// the MAC rule keeps firing, so the line still LOOKS redacted.
+ ///
+ /// Deliberately NOT keyed on a label. This side writes hex as `frame=…` (the clock diagnostic),
+ /// `[raw …]` and `(raw …)` (the alarm readback), and the #900 whole-frame dump carries none. A rule
+ /// enumerating today's phrasings is one the next diagnostic slips past. Matching the hex itself needs
+ /// no maintenance and covers dumps not yet written.
+ ///
+ /// Only bytes inside a serial-shaped ASCII run are masked; the rest of the dump survives, because the
+ /// payload is exactly where an undocumented field would be found. Twin of `redactHexDumpPii`.
+ nonisolated static func redactHexDump(_ hex: String) -> String {
+ let chars = Array(hex)
+ // NO even-length requirement, deliberately. The run regex matches consecutive hex characters, so
+ // a dump abutting other hex-valid text yields an ODD-length match — and bailing on that returned
+ // the serial UNREDACTED, while the Kotlin twin processed the even prefix and masked it. Swift was
+ // the weaker half of a pair that has to behave identically. Process what pairs up, ignore a
+ // trailing half-byte.
+ guard chars.count >= 16 else { return hex }
+ var bytes = [UInt8](); bytes.reserveCapacity(chars.count / 2)
+ var i = 0
+ while i + 1 < chars.count {
+ guard let b = UInt8(String(chars[i...(i + 1)]), radix: 16) else { return hex }
+ bytes.append(b); i += 2
+ }
+ var out = chars
+ var runStart = -1
+ func closeRun(_ end: Int) {
+ defer { runStart = -1 }
+ guard runStart >= 0 else { return }
+ // Anchor on the first LETTER with enough run left after it, mirroring the Kotlin regex
+ // `[A-Za-z][0-9A-Za-z]{8,}` — which finds a serial ANYWHERE inside an alphanumeric run, not
+ // only at its start. Testing just the run's first byte left the serial exposed whenever a
+ // digit happened to precede it with no separator, and Kotlin masked the same bytes. Two
+ // halves of one rule disagreeing about which payloads are safe is the failure to avoid.
+ var i = runStart
+ while i < end {
+ let b = bytes[i]
+ let isLetter = (b >= 65 && b <= 90) || (b >= 97 && b <= 122)
+ if isLetter, end - i >= 9 {
+ for k in i..= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122)
+ if alnum { if runStart < 0 { runStart = idx } } else { closeRun(idx) }
+ }
+ closeRun(bytes.count)
+ return String(out)
+ }
+
+ /// Tokens that identify a MODEL rather than a person, for `logSafeDeviceName`.
+ ///
+ /// Two shapes only, both EXACT: a known vendor, product or model word, and a version number ("4.0").
+ ///
+ /// There is deliberately no letters-plus-digits pattern for model codes. One was tried and it
+ /// defeated the whole design: "[a-z]{1,4}\\d{1,3}" matches "Ryan1" and "Sam99" as readily as "H10",
+ /// so a first name with a digit passed through untouched. A pattern cannot be an allowlist - the
+ /// moment a rule describes a SHAPE rather than a known value it admits everything else of that
+ /// shape. Model codes are therefore listed one by one.
+ ///
+ /// The cost is that an unlisted device logs as "" until its code is added, which is the right
+ /// direction to fail: a missing model is an inconvenience, a leaked name is not.
+ ///
+ /// Anything not on this list is DROPPED, which is the point: a naming shape nobody anticipated loses
+ /// by default. Kotlin twin: `SAFE_DEVICE_NAME_TOKEN_RE`.
+ private static let safeDeviceNameToken = try? NSRegularExpression(
+ pattern: "^(whoop|mg|polar|verity|sense|wahoo|tickr|garmin|hrm|forerunner|fenix|vantage|ignite|amazfit|huami|zepp|xiaomi|mi|band|coospo|magene|suunto|scosche|rhythm|kickr|tacx|elite|cateye|decathlon|kalenji|geonaute|h6|h7|h9|h10|h64|h808s|oh1|dual|\\d+(\\.\\d+)?)$", options: [.caseInsensitive])
+
+ /// A device name reduced to what is safe to put in a shared log: the MODEL, never the person.
+ ///
+ /// WHOOP seeds a strap's name from the account holder ("'s Whoop") and people rename
+ /// straps to anything at all. `redactPii` can only GUESS which words in a line are a name; here the
+ /// whole string IS the advertised name, so the safe move is an ALLOWLIST - keep the tokens known to
+ /// name a model and drop everything else. A naming shape nobody anticipated is then dropped by
+ /// default rather than needing a rule to catch it: "Ryan B's WHOOP 4.0" keeps only "WHOOP 4.0", and
+ /// "Dad's spare" keeps nothing.
+ ///
+ /// The "no name advertised" sentinel survives, because "we saw no name" and "we removed a name" are
+ /// different facts to whoever reads the log. Kotlin twin: `logSafeDeviceName`.
+ nonisolated static func logSafeDeviceName(_ name: String?) -> String {
+ let n = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if n.isEmpty || n == "unknown" { return "unknown" }
+ let tokens = n.split(whereSeparator: { $0.isWhitespace })
+ let safe = tokens.filter { tok in
+ guard let re = Self.safeDeviceNameToken else { return false }
+ let t = String(tok)
+ return re.firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) != nil
+ }
+ // Say "" only when something was actually removed. An unrenamed "WHOOP 4.0" or "Polar H10"
+ // carries nothing personal, and prefixing it would claim a redaction that never happened.
+ if safe.count == tokens.count { return n }
+ return safe.isEmpty ? "" : " " + safe.joined(separator: " ")
+ }
+
+ private static let hexRunRegex = try? NSRegularExpression(pattern: "[0-9a-fA-F]{16,}")
+
nonisolated static func redactPii(_ s: String) -> String {
var out = s
+ // Hex first: the text rules below must not see (or mangle) a run we are about to mask.
+ if let re = Self.hexRunRegex {
+ let ns = out as NSString
+ let matches = re.matches(in: out, range: NSRange(location: 0, length: ns.length))
+ if !matches.isEmpty {
+ var rebuilt = ""
+ var last = 0
+ for m in matches {
+ rebuilt += ns.substring(with: NSRange(location: last, length: m.range.location - last))
+ rebuilt += Self.redactHexDump(ns.substring(with: m.range))
+ last = m.range.location + m.range.length
+ }
+ rebuilt += ns.substring(from: last)
+ out = rebuilt
+ }
+ }
out = out.replacingOccurrences(
of: "([0-9A-Fa-f]{2}):[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:([0-9A-Fa-f]{2})",
with: "$1:••:••:••:••:$2", options: .regularExpression)
+ // #1193 field capture: the old rule required a DIGIT straight after "WHOOP ", but real serials
+ // start with letters as often as digits - "WHOOP MGB0779473" sat unredacted in a log attached to
+ // an issue while "WHOOP 4C1594026" beside it was masked. The rule now accepts any alnum run of 6+
+ // that CONTAINS a digit.
+ //
+ // The digit requirement is not decoration, it is what keeps this from eating words: "WHOOP PUFFIN
+ // service 1150" is a real diagnostic line, and PUFFIN is six alnum characters. A serial always
+ // carries a digit; a word does not. "WHOOP 4.0" stays untouched for a different reason - the dot
+ // stops the run at one character, short of the six the lookahead demands.
out = out.replacingOccurrences(
- of: "WHOOP (\\d[0-9A-Za-z]{5,})", with: "WHOOP ", options: .regularExpression)
+ of: "WHOOP (?=[0-9A-Za-z]{6,})[0-9A-Za-z]*[0-9][0-9A-Za-z]*", with: "WHOOP ", options: .regularExpression)
// Mask a CoreBluetooth peripheral UUID, but NOT a standard-BLE / WHOOP-vendor service UUID.
out = out.replacingOccurrences(
of: "(?![0-9A-Fa-f]{8}-(?:0000-1000-8000-00805f9b34fb|8d6d-82b8-614a-1c8cb0f8dcc6))[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
with: "", options: [.regularExpression, .caseInsensitive])
+ // #1303: an ADOPTED device id (`whoop-`) is a device identifier in every line that prints
+ // an id. Neither rule above catches it — the MAC rule wants MAC shape and the serial rule wants the
+ // literal "WHOOP " then a DIGIT, while an adopted id is `whoop-` + a serial commonly starting with
+ // a letter. Keeps three characters, matching `WhoopSerialIdentity.logSafe`, so two straps stay
+ // distinguishable; PRESERVES the `-noop` computed-sibling suffix, which is not identifying and is
+ // what lets a reader tell derived rows from measured ones. Six-character minimum matches
+ // `minSerialLength`, so `my-whoop` and `my-whoop-noop` are untouched. Kotlin twin in
+ // `redactStrapLogPii`.
+ out = out.replacingOccurrences(
+ of: "whoop-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}(-noop)",
+ with: "whoop-$1…$2", options: .regularExpression)
+ out = out.replacingOccurrences(
+ of: "whoop-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}",
+ with: "whoop-$1…", options: .regularExpression)
+ // The account holder's NAME, as WHOOP writes it into the advertised local name. WHOOP names a
+ // strap "'s Whoop" by default and the scan path logs that name on every discovery, so
+ // the shareable log (#445) we ask people to attach to public issues carried a real person's name.
+ // No rule above could see it: they key on MAC shape, "WHOOP " + digit, or a "whoop-" id.
+ //
+ // Keeps the possessive and whatever follows, so "Ryan's WHOOP 4.0" keeps the MODEL, which is
+ // diagnostic and identifies nobody. Matches the curly apostrophe because Apple platforms write
+ // U+2019 into default device names — a straight-quote-only rule would miss this platform's logs.
+ //
+ // LIMITATION, deliberate: exactly ONE token before the possessive, so "Ryan B's Whoop" keeps
+ // "Ryan". A multi-token rule cannot tell a name from the surrounding log text and would swallow
+ // "Discovered" with it. A fully custom name with no possessive stays a known gap. Kotlin twin in
+ // `redactStrapLogPii` as `PII_DEVICE_NAME_RE`.
+ out = out.replacingOccurrences(
+ of: "[\\p{L}\\p{N}_.\\-]+(['\u{2019}]s\\s+(?i:whoop))",
+ with: "$1", options: .regularExpression)
return out
}
diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift
index 58f037ce44..b0fd228409 100644
--- a/Strand/BLE/OuraLiveSource.swift
+++ b/Strand/BLE/OuraLiveSource.swift
@@ -13,6 +13,30 @@ import UIKit
import AppKit
#endif
+/// The label a discovered ring is listed under: its advertised local name, or `fallback` when it did
+/// not advertise one.
+///
+/// A BONDED ring routinely stops advertising a local name, so the blank case is the NORMAL case for the
+/// ring a user most wants to reconnect to (#1776), not an error path — the scan's service filter is the
+/// only gate and the name is a label. Callers therefore never see an empty `DiscoveredRing.name`, which
+/// is why the wizard renders it unguarded on both platforms (#1783 reads that as a missing guard; the
+/// guard is here, at the single construction site).
+///
+/// BLANK, not empty. The Kotlin twin is `ouraAdvertisedLabel` in `ble/OuraLiveSource.kt`, built on
+/// `ifBlank`, which treats an all-whitespace name as absent. `isEmpty` does not, so a ring advertising
+/// `" "` would have listed blank here and as "Oura" there — the exact silent Swift/Kotlin divergence the
+/// parity contract calls out. Both sides now treat whitespace as absent and return the name UNTRIMMED
+/// when it is present. Pinned by the Kotlin `OuraNamelessRingDiscoveryTest` against this function's own
+/// output. The two agree over ASCII whitespace, ordinary names, and U+00A0. `ifBlank` does NOT test
+/// `Character.isWhitespace`, which would indeed exclude U+00A0; it tests Kotlin's `Char.isWhitespace`,
+/// defined as `Character.isWhitespace(c) || Character.isSpaceChar(c)`, and the second disjunct restores
+/// every non-breaking space the first drops. The pair parts only on U+0085 (blank here, not in Kotlin)
+/// and U+001C–U+001F (blank in Kotlin, not here), which no BLE local name carries — stated rather than
+/// silently overclaimed.
+func ouraAdvertisedLabel(_ advertisedName: String, fallback: String) -> String {
+ advertisedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fallback : advertisedName
+}
+
/// EXPERIMENTAL, ISOLATED live-BLE source for the Oura ring (gen 3/4/5), driven by the clean-room
/// `OuraProtocol.OuraDriver`.
///
@@ -221,6 +245,13 @@ public final class OuraLiveSource: NSObject, ObservableObject {
private var loggedFirstTemp = false
/// Logs the FIRST SpO2 sample decoded this session only. Twin of `loggedFirstTemp`.
private var loggedFirstSpo2 = false
+ /// The 0x13 SyncTime reply parked because nothing yet available could disambiguate its unit (ticks vs
+ /// seconds x10): the resume cursor was 0 (fresh pair / post-reboot full pull) or so stale the ring's
+ /// clock had run past the window. Retried against the drain's `maxSeenRingTime` as the first batch
+ /// lands (2026-09-02/03 captures). The ORIGINAL receipt wall-clock is carried along, because THAT is the instant the
+ /// ring's counter pairs with - re-stamping at retry time would skew the anchor by the drain's latency.
+ private var pendingSyncTime: (deviceTimestamp: UInt32, status: UInt8, receivedAt: Int64)?
+
/// Logs the FIRST ring-time -> UTC anchor of this session only (s5.5); reset on stop/disconnect.
private var loggedAnchor = false
/// Tier-B (UNVERIFIED) kinds ("activity" / "real_steps" / "sleep_summary" / "spo2_smoothed") already
@@ -1304,6 +1335,7 @@ public final class OuraLiveSource: NSObject, ObservableObject {
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedAnchor = false
+ pendingSyncTime = nil
loggedTierBKinds.removeAll()
loggedFeatureStatuses.removeAll()
loggedProductInfo.removeAll()
@@ -1397,8 +1429,10 @@ public final class OuraLiveSource: NSObject, ObservableObject {
fetchHistoryIfIdle() // pull last night's banked temp/SpO2/HRV/sleep-phase right away
write([OuraCommands.getBattery()]) // ask once HR streams; the 0x0D reply routes to onBattery
// Read-only diagnostic: ask the ring its SpO2 / real-steps feature status once, so a capture
- // confirms (from the ring itself) that these server-flag features are subscription-gated OFF
- // for an offline ring. NEVER an enable/set-mode write - purely the 0x20 read verb.
+ // sees the ring's own gate state. NOT reliably "off" for an offline ring — on-device
+ // 2026-08-25 real_steps read back status=1 (enabled) here, matching the real Oura app's read
+ // of the same ring byte-for-byte (worklog analysis/2026-08-25-noop-ring-hci-realsteps-
+ // confirmation.txt). NEVER an enable/set-mode write - purely the 0x20 read verb.
write([OuraCommands.spo2ReadStatus(), OuraCommands.realStepsReadStatus()])
// Read-only capture (#771/#772): the ring's GetProductInfo serial + hardware pages are
// pre-auth readable. The SERIAL is a STABLE per-ring identity — unlike the CoreBluetooth UUID,
@@ -1539,6 +1573,10 @@ public final class OuraLiveSource: NSObject, ObservableObject {
drain.noteSeenRingTime(rt)
}
}
+ // The drain now has a ring-time reference that needs no anchor, so a 0x13 reply parked at connect
+ // may be resolvable. Retry BEFORE `ingest`, so this batch anchors straight away instead of parking
+ // into `pendingAnchorEvents` and being drained a moment later (2026-09-02/03 captures).
+ retryPendingSyncTimeAnchor()
if pendingContinuation { restartBatchQuietTimer() }
ingest(events)
}
@@ -2199,15 +2237,20 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate {
rssi RSSI: NSNumber) {
let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let name = advName ?? peripheral.name ?? ""
- // The scan already filters on the Oura service, but re-check the name through the gen recogniser
- // so a coincidental service match without an Oura-shaped name is dropped (best-effort).
+ // Best-effort generation guess from the advertised name — a LABEL only (the wizard falls back to
+ // .gen3 when it is nil). Nothing is dropped here: the scan's service filter is the only gate, so a
+ // ring is listed even when it advertises no local name at all. That is deliberate — a BONDED ring
+ // often stops advertising one, and treating the empty name as a miss is the bug the Android twin
+ // carried (`ExperimentalBrand.recognise(name) != OURA` in `ble/OuraLiveSource.kt`, where
+ // `recognise("")` is nil) until it was brought in line with this side. Do not "restore" a
+ // name-based drop on either platform.
let detectedGen = OuraRingGen.recognise(advertisedName: name)
let id = peripheral.identifier
let firstSight = seenPeripherals[id] == nil
seenPeripherals[id] = peripheral
- if firstSight { log("Oura: found \(name.isEmpty ? "Oura ring" : name) (\(id)) rssi \(RSSI.intValue)") }
+ if firstSight { log("Oura: found \(ouraAdvertisedLabel(name, fallback: "Oura ring")) (\(id)) rssi \(RSSI.intValue)") }
let ring = DiscoveredRing(id: id,
- name: name.isEmpty ? "Oura" : name,
+ name: ouraAdvertisedLabel(name, fallback: "Oura"),
rssi: RSSI.intValue,
detectedGen: detectedGen)
if let idx = discovered.firstIndex(where: { $0.id == id }) {
@@ -2246,6 +2289,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate {
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedAnchor = false
+ pendingSyncTime = nil
loggedTierBKinds.removeAll()
loggedFeatureStatuses.removeAll()
loggedProductInfo.removeAll()
@@ -2331,6 +2375,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate {
loggedFirstTemp = false
loggedFirstSpo2 = false
loggedAnchor = false
+ pendingSyncTime = nil
loggedTierBKinds.removeAll()
loggedFeatureStatuses.removeAll()
loggedProductInfo.removeAll()
@@ -2516,28 +2561,56 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate {
ingestHistory(driver.ingest(notification: bytes, reassembler: reassembler))
}
+ /// The ring-time floor the 0x13 unit test disambiguates against: the persisted resume cursor, or the
+ /// largest envelope ring-time this drain has seen when that is further along. `maxSeenRingTime` is the
+ /// half that breaks the deadlock (2026-09-02/03 captures) — it counts EVERY history record, anchored or not, so it is
+ /// readable with no anchor, whereas the cursor can only advance once an anchor exists.
+ private var syncTimeAnchorLowerBound: UInt32 {
+ max(historyCursor, drain.maxSeenRingTime)
+ }
+
+ /// Try to turn a 0x13 SyncTime reply into this session's UTC anchor, returning whether it stuck.
+ /// `receivedAt` is the host wall-clock at the moment the reply ARRIVED (not now), since that is the
+ /// instant the ring's counter pairs with. Shared by the at-connect attempt and the retry.
+ private func adoptSyncTimeAnchor(deviceTimestamp: UInt32, status: UInt8, receivedAt: Int64, source: String) -> Bool {
+ guard let driver else { return false }
+ guard let rt = OuraDriver.syncTimeAnchorCandidate(responseValue: deviceTimestamp,
+ lowerBoundTicks: syncTimeAnchorLowerBound),
+ driver.adoptSyncTimeAnchor(ringTimestamp: rt, unixSeconds: receivedAt) else { return false }
+ let unit = rt == deviceTimestamp ? "ticks" : "seconds x10"
+ let raw = String(format: "0x%08x", deviceTimestamp)
+ if !loggedAnchor {
+ loggedAnchor = true
+ log("Oura: UTC anchor from SyncTime response (0x13) \(source) - device rt \(rt) [\(unit), raw \(raw), status \(status)] = its receipt time; no 0x42 needed this session")
+ }
+ drainPendingAnchorEvents()
+ drainPendingHypnogramBursts()
+ return true
+ }
+
/// Anchor from the 0x13 SyncTime response (ringverse BLE.md `13 05 `):
/// the ring's clock counter at the moment it processed our SyncTime, paired with the host wall-clock
- /// at receipt. The tick unit is disambiguated against the persisted resume cursor
- /// (OuraDriver.syncTimeAnchorCandidate — raw ticks vs seconds×10, exactly one must be plausible);
- /// no unambiguous reading → log the raw value for investigation and adopt NOTHING (an honest missing
- /// anchor beats a guessed one). On success everything parked while unanchored resolves immediately.
+ /// at receipt. The tick unit is disambiguated against a known-earlier ring-time
+ /// (OuraDriver.syncTimeAnchorCandidate — raw ticks vs seconds×10, exactly one must be plausible).
+ /// At connect the only reference is the resume cursor, which is 0 on a fresh pair and may be far
+ /// staler than the ring's clock; rather than discard the reply, PARK it and retry once history starts
+ /// landing (2026-09-02/03 captures). Still adopts NOTHING on ambiguity — an honest missing anchor beats a guessed one.
private func handleSyncTimeResponse(_ resp: (deviceTimestamp: UInt32, status: UInt8)) {
- guard let driver else { return }
let now = Int64(Date().timeIntervalSince1970)
+ if adoptSyncTimeAnchor(deviceTimestamp: resp.deviceTimestamp, status: resp.status,
+ receivedAt: now, source: "at connect") { return }
+ pendingSyncTime = (resp.deviceTimestamp, resp.status, now)
let raw = String(format: "0x%08x", resp.deviceTimestamp)
- if let rt = OuraDriver.syncTimeAnchorCandidate(responseValue: resp.deviceTimestamp,
- historyCursor: historyCursor),
- driver.adoptSyncTimeAnchor(ringTimestamp: rt, unixSeconds: now) {
- let unit = rt == resp.deviceTimestamp ? "ticks" : "seconds x10"
- if !loggedAnchor {
- loggedAnchor = true
- log("Oura: UTC anchor from SyncTime response (0x13) - device rt \(rt) [\(unit), raw \(raw), status \(resp.status)] = now; no 0x42 needed this session")
- }
- drainPendingAnchorEvents()
- drainPendingHypnogramBursts()
- } else {
- log("Oura: SyncTime response (0x13) raw \(raw) status \(resp.status) - no unambiguous tick reading vs cursor \(historyCursor); anchor NOT adopted (investigation)")
+ log("Oura: SyncTime response (0x13) raw \(raw) status \(resp.status) - no unambiguous tick reading vs ring-time floor \(syncTimeAnchorLowerBound); parked, retrying as history lands (investigation)")
+ }
+
+ /// Retry a parked 0x13 reply now that the drain has seen real ring-times. Silent on failure (the
+ /// parked reply simply waits for a better floor); one line on success, from `adoptSyncTimeAnchor`.
+ private func retryPendingSyncTimeAnchor() {
+ guard let parked = pendingSyncTime, drain.maxSeenRingTime > 0 else { return }
+ if adoptSyncTimeAnchor(deviceTimestamp: parked.deviceTimestamp, status: parked.status,
+ receivedAt: parked.receivedAt, source: "resolved against history") {
+ pendingSyncTime = nil
}
}
diff --git a/Strand/BLE/PuffinDeepBufferLog.swift b/Strand/BLE/PuffinDeepBufferLog.swift
index 56b0dff2e3..07c919da26 100644
--- a/Strand/BLE/PuffinDeepBufferLog.swift
+++ b/Strand/BLE/PuffinDeepBufferLog.swift
@@ -34,9 +34,9 @@ enum PuffinOpticalExperimentPhase: String, CaseIterable, Sendable {
/// packets that carry tens-of-Hz sensor data (motion + optical) rather than the 1 Hz historical
/// rollup NOOP already decodes (#423).
///
-/// On-strap probing (#423) established that the 5/MG has no live raw-IMU stream, but it DOES bank
-/// high-rate motion (accel content verified: int16-ish triples sphere-fitting to ~1 g at the 1/4096
-/// scale) and ships it inside big type-0x2F buffers during the connect-time offload burst — 124 B
+/// On-strap probing (#423) established that the 5/MG banks high-rate motion and can stream it live
+/// during an explicitly armed raw-data session (accel content verified: int16-ish triples
+/// sphere-fitting to ~1 g at the 1/4096 scale). History also ships it inside big type-0x2F buffers — 124 B
/// (≈1 Hz record), 1244 B and 2140 B (≈32 and ≈59 sub-records per timestamped second). NOOP's
/// historical decoder pulls the 1 Hz gravity vector out and DISCARDS the high-rate remainder. Those
/// bursts are ephemeral and backlog-gated, so a byte-perfect decoder can't be captured on demand — it
diff --git a/Strand/BLE/SourceCoordinator.swift b/Strand/BLE/SourceCoordinator.swift
index 5b5e87d4cd..a8f2fb1870 100644
--- a/Strand/BLE/SourceCoordinator.swift
+++ b/Strand/BLE/SourceCoordinator.swift
@@ -588,7 +588,9 @@ final class SourceCoordinator: ObservableObject {
}
/// A device is WHOOP when its brand is "WHOOP" (the seeded `my-whoop` row's brand).
- static func isWhoop(_ device: PairedDevice) -> Bool {
- device.id == "my-whoop" || device.brand.caseInsensitiveCompare("WHOOP") == .orderedSame
- }
+ ///
+ /// #1881 gave the BLE engine the same question to answer, so the rule now lives in ONE place
+ /// (`SourceIdentity`, beside `PairedDevice`) and this delegates. Two spellings of "is this a WHOOP"
+ /// that could disagree is precisely how a strap's samples end up filed under a ring.
+ static func isWhoop(_ device: PairedDevice) -> Bool { SourceIdentity.isWhoop(device) }
}
diff --git a/Strand/BLE/StandardHRSource.swift b/Strand/BLE/StandardHRSource.swift
index 424f7c0c04..1fda83e60d 100644
--- a/Strand/BLE/StandardHRSource.swift
+++ b/Strand/BLE/StandardHRSource.swift
@@ -126,9 +126,9 @@ public final class StandardHRSource: NSObject, ObservableObject {
// MARK: - Sample buffer
- /// Buffered (hr, rr, ts) readings, flushed to `persist` in batches to keep the write path off
+ /// Buffered (hr, rr, contact, ts) readings, flushed to `persist` in batches to keep the write path off
/// the per-notification hot loop.
- private var buffer: [(hr: Int, rr: [Int], ts: Int)] = []
+ private var buffer: [(hr: Int, rr: [Int], contact: StandardHRContact, ts: Int)] = []
private var lastFlush: Date = .init()
/// Flush thresholds — whichever trips first.
private let flushCount = 30
@@ -230,8 +230,8 @@ public final class StandardHRSource: NSObject, ObservableObject {
// MARK: - Buffer / persistence
- private func enqueue(hr: Int, rr: [Int]) {
- buffer.append((hr: hr, rr: rr, ts: Int(Date().timeIntervalSince1970)))
+ private func enqueue(hr: Int, rr: [Int], contact: StandardHRContact) {
+ buffer.append((hr: hr, rr: rr, contact: contact, ts: Int(Date().timeIntervalSince1970)))
if buffer.count >= flushCount || Date().timeIntervalSince(lastFlush) >= flushInterval {
flush()
}
@@ -240,7 +240,8 @@ public final class StandardHRSource: NSObject, ObservableObject {
private func flush() {
guard !buffer.isEmpty else { lastFlush = Date(); return }
for sample in buffer {
- persist(StandardHRMapping.samples(fromHR: sample.hr, rr: sample.rr, at: sample.ts))
+ persist(StandardHRMapping.samples(fromHR: sample.hr, rr: sample.rr,
+ contact: sample.contact, at: sample.ts))
}
buffer.removeAll()
lastFlush = Date()
@@ -304,7 +305,7 @@ extension StandardHRSource: @preconcurrency CBCentralManagerDelegate {
seenPeripherals[id] = peripheral
let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let name = advName ?? peripheral.name ?? "Heart Rate Strap"
- if firstSight { log("HR-strap: found \(name) (\(id)) rssi \(RSSI.intValue)") }
+ if firstSight { log("HR-strap: found \(LiveState.logSafeDeviceName(name)) (\(id)) rssi \(RSSI.intValue)") }
let strap = DiscoveredStrap(id: id, name: name, rssi: RSSI.intValue)
discovered = Self.upsertByProximity(discovered, strap)
// If we were scanning specifically to reach this strap (a not-yet-cached active strap), connect now
@@ -478,6 +479,6 @@ extension StandardHRSource: @preconcurrency CBPeripheralDelegate {
live.heartRate = parsed.hr
live.setRRIntervals(parsed.rr)
live.connected = true
- enqueue(hr: parsed.hr, rr: parsed.rr)
+ enqueue(hr: parsed.hr, rr: parsed.rr, contact: parsed.contact)
}
}
diff --git a/Strand/BLE/StandardHeartRate.swift b/Strand/BLE/StandardHeartRate.swift
index c1badb8bf6..5feb063dd0 100644
--- a/Strand/BLE/StandardHeartRate.swift
+++ b/Strand/BLE/StandardHeartRate.swift
@@ -1,11 +1,13 @@
import Foundation
+import WhoopProtocol
/// Pure parser for the standard BLE Heart Rate Measurement characteristic (0x2A37).
-/// Returns the heart rate (bpm) and any R-R intervals (ms). Pure → unit-testable.
+/// Returns the heart rate (bpm), R-R intervals (ms), and the contact state. Pure → unit-testable.
public enum StandardHeartRate {
- public static func parse(_ data: [UInt8]) -> (hr: Int, rr: [Int])? {
+ public static func parse(_ data: [UInt8]) -> (hr: Int, rr: [Int], contact: StandardHRContact)? {
guard !data.isEmpty else { return nil }
let flags = data[0]
+ let contact = StandardHRContact.fromMeasurementFlags(flags)
var idx = 1
let hr: Int
if flags & 0x01 != 0 { // 16-bit HR
@@ -24,6 +26,6 @@ public enum StandardHeartRate {
idx += 2
}
}
- return (hr, rr)
+ return (hr, rr, contact)
}
}
diff --git a/Strand/Collect/Backfiller.swift b/Strand/Collect/Backfiller.swift
index 1f78b47a67..59993ebf69 100644
--- a/Strand/Collect/Backfiller.swift
+++ b/Strand/Collect/Backfiller.swift
@@ -48,6 +48,11 @@ final class Backfiller {
/// of end_data, used for the `strap_trim` cursor) and the 8-byte `end_data` (= the raw
/// HISTORY_END metadata.data[10:18]) that the high-freq-sync ack form requires verbatim.
private let ackTrim: (_ trim: UInt32, _ endData: [UInt8]) -> Void
+ /// #1635: one offload chunk's accepted-row counts, handed up so `BLEManager` can tally them per LINK.
+ /// The offload is the only path that banks gravity/resp/skinTemp/SpO2/steps, so a link summary
+ /// without it cannot tell an unbonded strap — which defers backfill — from a healthy one.
+ private let onBankedOffload: (_ counts: (hr: Int, rr: Int, events: Int, battery: Int,
+ spo2: Int, skinTemp: Int, resp: Int, gravity: Int)) -> Void
private let extract: Extractor
/// Research toggle. When false (DEFAULT) no raw frames are persisted — the chunk's
/// decoded streams are still durable and the trim is still acked (decoded is the product of
@@ -142,6 +147,11 @@ final class Backfiller {
/// Logged once per session when the strap reports trim=0xFFFFFFFF — the "no valid flash cursor"
/// sentinel: it has no banked history to offload (a clock/charge state, not a decode bug).
private var loggedNoCursor = false
+ /// #1754: whether THIS session saw the trim=0xFFFFFFFF "no valid flash cursor" sentinel. Exposed so
+ /// the empty-offload banner can distinguish the clock/charge state (no cursor — the existing copy is
+ /// correct) from a strap that has a valid, advancing flash cursor but banks no sensor records (NOT a
+ /// clock problem — points at the sensor front-end or power). Read-only from outside.
+ var sawNoFlashCursor: Bool { loggedNoCursor }
/// #773: logged once per session the first time a HISTORY_END's own timestamp is dated implausibly far
/// in the FUTURE (a corrupt strap RTC). Distinct from #547's per-record drop tally: this fires on the
/// chunk metadata's own clock, the earliest visible tell that the strap's RTC is bogus. Reset in begin().
@@ -209,6 +219,9 @@ final class Backfiller {
init(store: BackfillStoreWriting,
deviceId: String,
ackTrim: @escaping (_ trim: UInt32, _ endData: [UInt8]) -> Void,
+ onBankedOffload: @escaping (_ counts: (hr: Int, rr: Int, events: Int, battery: Int,
+ spo2: Int, skinTemp: Int, resp: Int,
+ gravity: Int)) -> Void = { _ in },
enableRawCapture: Bool = false,
log: ((String) -> Void)? = nil,
rejectedSink: ((_ frames: [[UInt8]], _ trim: UInt32, _ family: DeviceFamily) -> Bool)? = nil,
@@ -225,6 +238,7 @@ final class Backfiller {
self.store = store
self.deviceId = deviceId
self.ackTrim = ackTrim
+ self.onBankedOffload = onBankedOffload
self.enableRawCapture = enableRawCapture
self.log = log
self.rejectedSink = rejectedSink
@@ -428,6 +442,69 @@ final class Backfiller {
return "Backfill: the strap reported a record dated about \(aheadDays) day(s) in the FUTURE - its clock (RTC) is corrupt, not a NOOP problem. Those records can't be filed onto the right day. Fully charge the strap to 100% and reconnect so it re-syncs its clock; if it persists, forget and re-pair the strap."
}
+ /// #1683: how far BEHIND the wall clock the strap's newest stored record may sit before a sync that
+ /// banked nothing is worth explaining. Two days, not one: a strap left off-wrist overnight is ordinary,
+ /// and this line only ever accompanies a completed offload that banked nothing anyway.
+ nonisolated static let staleRecordToleranceSeconds = 2 * 86_400
+
+ /// Is the strap's newest stored record far enough in the past to be worth naming? A nil or
+ /// non-positive value is not a date and never qualifies.
+ nonisolated static func isStaleNewestRecord(newestUnix: Int?, wallNowUnix: Int) -> Bool {
+ guard let newestUnix, newestUnix > 0 else { return false }
+ return newestUnix <= wallNowUnix - staleRecordToleranceSeconds
+ }
+
+ /// #1683: the counterpart `futureRtcLine` never had. A strap that stopped banking weeks ago and one
+ /// that is simply caught up produce the SAME "banked no sensor history" line today, so neither the user
+ /// nor anyone reading their log can tell them apart. #1541 stayed open and vague for exactly that
+ /// reason.
+ ///
+ /// Deliberately states the FACT and lets the condition carry the interpretation. A newest record two
+ /// weeks old means the strap stopped recording IF it was being worn; if it sat in a drawer, the same
+ /// number is unremarkable. Asserting a corrupt RTC here would claim more than the data supports, which
+ /// is how this area has misled people before.
+ ///
+ /// It also says the part the existing advice omits: NOOP re-sends SET_CLOCK on every connect, so
+ /// "charge it" alone has already been retried every session.
+ ///
+ /// Byte-identical to the Android twin. No em-dash (project rule).
+ nonisolated static func staleRecordLine(newestUnix: Int, wallNowUnix: Int) -> String {
+ let ageDays = max(0, wallNowUnix - newestUnix) / 86_400
+ return "Backfill: this sync banked nothing and the strap's newest stored record is about \(ageDays) day(s) old. If you have worn it since then, it has stopped saving history to its flash. NOOP already re-sends the clock on every connect, so charging alone may not be enough: charge to 100% and reconnect, then use Restart strap in Devices, and if that does not help forget and re-pair. If the official WHOOP app is also missing these days, the strap is the cause and not NOOP."
+ }
+
+
+ /// #1683: the same honesty as `staleRecordLine`, for the message the user actually READS.
+ ///
+ /// The standing banner says "fully charge it to 100%, then reconnect, and it should start banking
+ /// again". It omits the one fact that makes the situation legible - how long the strap has been
+ /// silent - and it PROMISES a recovery that has already failed every session for weeks, because NOOP
+ /// re-sends SET_CLOCK on every connect and the charge advice has therefore been retried all along. A
+ /// banner that keeps promising something that keeps not happening teaches people to distrust the app
+ /// rather than their strap.
+ ///
+ /// Byte-identical to the Android twin. Not localized, matching the sibling `lastSyncError` copy on
+ /// both platforms; localizing that surface is its own change. No em-dash (project rule).
+ nonisolated static func staleRecordBanner(newestUnix: Int, wallNowUnix: Int) -> String {
+ let ageDays = max(0, wallNowUnix - newestUnix) / 86_400
+ return "Synced, but your strap handed over no stored history, and its newest saved record is about \(ageDays) day(s) old. If you have been wearing it since then, it has stopped saving to flash. Charge it to 100% and reconnect; NOOP already re-sets its clock every connect, so if that does not help, try Restart strap in Devices, then forget and re-pair. If the official WHOOP app is missing these days too, the strap is the cause and not NOOP."
+ }
+
+ /// #1754: the banner for an empty offload whose flash cursor is VALID and ADVANCING — the strap is
+ /// writing pages but banking no sensor records, so the clock/charge advice does not apply. The
+ /// cause points at the sensor front-end or power state, not the RTC. Byte-identical to the Android
+ /// twin (`Backfiller.noSensorRecordsBanner`). Not localized, matching the sibling `lastSyncError`
+ /// copy on both platforms; localizing that surface is its own change. No em-dash (project rule).
+ nonisolated static let noSensorRecordsBanner =
+ "Synced, but your strap handed over no sensor records - only its diagnostic output. The strap's flash cursor is valid and advancing, so this is not a clock problem; it points at the sensor front-end or power state. If this persists across reconnects, please share a strap log so the cause can be identified."
+
+ /// #1754: the banner for an empty offload whose flash cursor is the 0xFFFFFFFF sentinel — the strap
+ /// has no banked history at all, the clock/charge advice IS correct. Kept as a constant so the two
+ /// banners stay side by side and the caller's branch reads as a choice between two named states.
+ /// Byte-identical to the Android twin (`Backfiller.noFlashCursorBanner`).
+ nonisolated static let noFlashCursorBanner =
+ "Synced, but your strap had no stored history to hand over - only its diagnostic output. This usually means its clock has lost sync, so it isn't saving data to flash. Fully charge it to 100%, then reconnect, and it should start banking again."
+
/// Commit one HISTORY_END chunk: (persist decoded → enqueueRaw when present) → setCursor → ackTrim.
/// Early-returns on any throw to preserve the safe-trim invariant.
///
@@ -658,7 +735,10 @@ final class Backfiller {
// emission can be measured, since every existing R-R number is taken after the ON CONFLICT key
// has already absorbed part of it.
let rrCensus = RrEmissionStats.compute(decoded.rr.map { (ts: $0.ts, rrMs: $0.rrMs) })
- do { counts = try await store.insert(decoded, deviceId: deviceId) } catch {
+ do {
+ counts = try await store.insert(decoded, deviceId: deviceId)
+ onBankedOffload(counts)
+ } catch {
// Diag (#601): the decoded rows couldn't be written — this is the "history stalls but live HR
// works" class. We return WITHOUT acking so the strap keeps this chunk and re-sends it next
// session (no data loss), but a silent return left a strap log with no trace of the stall.
diff --git a/Strand/Collect/ClockCorrelation.swift b/Strand/Collect/ClockCorrelation.swift
index 9d16d366fa..9188ff61de 100644
--- a/Strand/Collect/ClockCorrelation.swift
+++ b/Strand/Collect/ClockCorrelation.swift
@@ -8,8 +8,19 @@ import WhoopStore
/// No CoreBluetooth, no I/O — fully unit-testable.
enum ClockCorrelation {
/// Build a `ClockRef` from a decoded GET_CLOCK COMMAND_RESPONSE frame and the wall
- /// time observed when the response arrived. Returns nil unless the frame parsed OK,
- /// passed CRC, and carries a `clock` value.
+ /// time observed when the response arrived. Returns nil unless the frame is INTACT and carries a
+ /// `clock` value.
+ ///
+ /// `ok` is the verifier's full verdict — header checksum, payload CRC32 and structural length
+ /// together — so one condition now covers what the two-part check used to. The stake is why this
+ /// gate is worth stating plainly: the anchor set here converts every live device timestamp into wall
+ /// time, and until it is set the Collector and the Backfiller both hold their rows. An anchor taken
+ /// from a damaged frame does not block persistence, it timestamps everything wrongly, which is worse
+ /// — so an unverifiable frame leaves the anchor unset and the downstream storage blocked.
+ ///
+ /// The `crcOK` condition is kept for parse results that did not come from a fresh parse: a
+ /// `ParsedFrame` decoded from a capture written before the verdict widened carries the old constant
+ /// `ok: true` beside a false `crcOK`.
static func clockRef(from parsed: ParsedFrame, wall: Int) -> ClockRef? {
guard parsed.ok, parsed.crcOK != false,
let device = parsed.parsed["clock"]?.intValue else { return nil }
diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift
index 87279bfa0a..e54b2bfea5 100644
--- a/Strand/Collect/Collector.swift
+++ b/Strand/Collect/Collector.swift
@@ -1,6 +1,7 @@
import Foundation
import WhoopProtocol
import WhoopStore
+import StrandAnalytics
/// The subset of WhoopStore the Collector needs. A protocol so tests can inject a spy
/// (WhoopStore is `final`). WhoopStore conforms via the extension below.
@@ -12,12 +13,6 @@ protocol StoreWriting: AnyObject {
-> (hr: Int, rr: Int, events: Int, battery: Int,
spo2: Int, skinTemp: Int, resp: Int, gravity: Int)
func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws
- func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws
-}
-extension StoreWriting {
- /// #423: default no-op so a test SpyStore needn't implement the raw-IMU capture path. WhoopStore's
- /// real impl (StreamStore.swift) satisfies the requirement and is used in production.
- func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws {}
}
extension WhoopStore: StoreWriting {}
@@ -72,20 +67,67 @@ final class Collector {
/// #47: buffer the (raw frame, pre-parsed) pair. The raw bytes are still needed for the raw-capture
/// outbox; the parse is the one the BLE seam already did, so `flush` doesn't re-decode the batch.
private var buffer: [(frame: [UInt8], parsed: ParsedFrame)] = []
- /// Standard 0x2A37 HR/RR buffer — the reliable, always-on stream, recorded continuously
+ /// #1118: strap-log sink for the per-transport R-R census. Optional and defaulted to nil so the
+ /// test fakes that construct a Collector are untouched; `BLEManager` wires its own `log`.
+ private let log: ((String) -> Void)?
+ /// #1635: rows ACCEPTED per stream, handed up so `BLEManager` can tally them per LINK and say which
+ /// streams banked when it writes the link epitaph. The counts already exist — `StreamStore.insert`
+ /// returns them and the standard-HR path already binds them for its own trace line — so this carries
+ /// a measurement that was being discarded, rather than taking a new one.
+ private let onBanked: ((BankedCounts) -> Void)?
+ /// #1118: last emit of each LIVE census line, unix seconds; 0 = never. Rate-limited — see
+ /// `RrEmissionStats.shouldEmitLiveCensus`.
+ ///
+ /// Lifetime DIVERGES from the Kotlin twin. These reset whenever `BLEManager.bootstrapStore()`
+ /// rebuilds the Collector (a store rebuild after unlock, among other paths), so a log can carry an
+ /// extra line after one of those. Android keeps its stamps on the process-wide `WhoopBleClient`
+ /// singleton, which a device switch mutates rather than rebuilds, so they never reset there.
+ /// Harmless either way — a rate-limit on a diagnostic, not a measurement — but a reader comparing
+ /// two logs should not have to work out why one has more lines than the other.
+ ///
+ /// @MainActor isolation makes these safe without a lock; the Kotlin fields are deliberately
+ /// unsynchronized instead, since a stale read there costs one duplicate line.
+ private var lastStdRrCensusSec: Int = 0
+ private var lastRealtimeRrCensusSec: Int = 0
+
+ /// Consecutive live-persist failures per transport, and when each last reported.
+ ///
+ /// Kept PER TRANSPORT because the standard 0x2A37 path and the puffin REALTIME_DATA path (#1118) fail
+ /// independently — a shared counter would let one path's success reset the other's run and report a
+ /// persistent failure as a string of first-failures. @MainActor isolation makes these safe without a
+ /// lock; the Kotlin twin uses AtomicInteger because its two flushes can run concurrently on the io
+ /// scope, and there the count is the load-bearing distinction between a transient and a run.
+ private var stdInsertFailures = 0
+ private var realtimeInsertFailures = 0
+ private var lastStdInsertFailureLogMs: Int64 = 0
+ private var lastRealtimeInsertFailureLogMs: Int64 = 0
+
+ /// Standard 0x2A37 HR/RR/contact buffer — the reliable, always-on stream, recorded continuously
/// (independent of the custom realtime stream or which screen is open).
private var stdHR: [HRSample] = []
private var stdRR: [RRInterval] = []
+ private var stdContact: [WhoopEvent] = []
+ /// Last contact state buffered, so only transitions are recorded. See `shouldRecordContact`.
+ private var lastStdContact: StandardHRContact?
private var batchStartedAt: TimeInterval
var bufferedCount: Int { buffer.count }
+ /// The per-stream accepted-row counts `StreamStore.insert` returns, named so the closure that carries
+ /// them is readable at both ends.
+ typealias BankedCounts = (hr: Int, rr: Int, events: Int, battery: Int,
+ spo2: Int, skinTemp: Int, resp: Int, gravity: Int)
+
init(store: StoreWriting, deviceId: String,
policy: CollectorPolicy = .default,
enableRawCapture: Bool = false,
+ log: ((String) -> Void)? = nil,
+ onBanked: ((BankedCounts) -> Void)? = nil,
now: @escaping () -> Int = { Int(Date().timeIntervalSince1970) },
monotonic: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate }) {
self.store = store; self.deviceId = deviceId; self.policy = policy
self.enableRawCapture = enableRawCapture
+ self.log = log
+ self.onBanked = onBanked
self.now = now; self.monotonic = monotonic
self.batchStartedAt = monotonic()
self.concreteStore = store as? WhoopStore
@@ -97,6 +139,28 @@ final class Collector {
return try? await s.storageStats()
}
+ /// Decoded history rows in a stable long-form CSV for arbitrary user-selected export windows.
+ func historySensorsCSV(from: Int, to: Int) async -> Data {
+ guard let store = concreteStore, from <= to else { return Data("stream,unix_s,v1,v2,v3,v4\n".utf8) }
+ let limit = min(max(to - from + 1, 1) * 4, 1_000_000)
+ async let hr = try? store.hrSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let battery = try? store.batterySamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let spo2 = try? store.spo2Samples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let temp = try? store.skinTempSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let steps = try? store.stepSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let resp = try? store.respSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let gravity = try? store.gravitySamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ var lines = ["stream,unix_s,v1,v2,v3,v4"]
+ lines += await (hr ?? []).map { "heart_rate,\($0.ts),\($0.bpm),,," }
+ lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map { String($0) } ?? ""),\($0.mv.map { String($0) } ?? ""),," }
+ lines += await (spo2 ?? []).map { "spo2_raw,\($0.ts),\($0.red),\($0.ir),," }
+ lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map { String($0) } ?? ""),\($0.aux2Raw.map { String($0) } ?? "")," }
+ lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map { String($0) } ?? ""),," }
+ lines += await (resp ?? []).map { "resp_raw,\($0.ts),\($0.raw),,," }
+ lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map { String($0) } ?? "")" }
+ return Data((lines.joined(separator: "\n") + "\n").utf8)
+ }
+
/// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog).
/// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats().
func latestHRSampleTs() async -> Int? {
@@ -128,21 +192,6 @@ final class Collector {
ingest(frame: frame, parsed: parseFrame(frame, family: family))
}
- /// #423: persist the WHOOP 5/MG raw-IMU offload buffer NOOP already decodes for the deep-buffer log —
- /// the queryable twin of that (table-less) diagnostics line. Same `noopPuffinCapture` gate; only the
- /// 1244-B 6-axis buffer decodes (rawColumns nil otherwise). Fire-and-forget into the store, bounded by
- /// a rolling retention prune. Raw i16, no downstream consumer yet. Twin of Android
- /// `WhoopBleClient.storeWhoop5RawImuIfBuffer`.
- func storeRawImu(frame: [UInt8]) {
- guard UserDefaults.standard.bool(forKey: PuffinFrameRecorder.enabledKey) else { return }
- guard let cols = Whoop5RawImu.rawColumns(frame), let baseTs = Whoop5RawImu.baseTs(frame) else { return }
- let dev = deviceId
- Task { [store] in
- try? await store.insertRawImu(
- deviceId: dev, rows: [(ts: baseTs, cols: cols)], retentionRows: WhoopStore.rawImuRetentionRows)
- }
- }
-
/// Buffer one complete frame + its pre-parsed decode (synchronous: preserves delegate arrival order).
/// Auto-flushes via a detached Task when the cadence threshold is hit (flush is async). (#47)
func ingest(frame: [UInt8], parsed: ParsedFrame) {
@@ -150,6 +199,7 @@ final class Collector {
assert(parsed == parseFrame(frame, family: family),
"Collector.ingest: threaded ParsedFrame != fresh parse (#47 parse-once invariant)")
#endif
+ recordGroundTruthImu(frame)
buffer.append((frame, parsed))
// Pre-clock only: bound memory if GET_CLOCK never lands while data keeps flowing.
// Drop OLDEST beyond the cap (keep most recent). Post-clock this branch is skipped —
@@ -163,6 +213,7 @@ final class Collector {
}
}
+
/// Persist + queue everything buffered. No-op when empty or before a clock ref exists.
/// Buffer is snapshotted and cleared SYNCHRONOUSLY before the first await so that any
/// concurrent ingest() calls during persistence accumulate into the NEXT batch cleanly.
@@ -176,11 +227,38 @@ final class Collector {
let frames = batch.map(\.frame) // still needed for the raw-capture outbox
let parsed = batch.map(\.parsed) // #47: the seam already decoded these — don't re-parse
let streams = extractStreams(parsed, deviceClockRef: ref.device, wallClockRef: ref.wall)
+ // #1118: the SECOND live transport. `flushStandardHR` stamps a beat at the second it arrived over
+ // 0x2A37; this one stamps it from the strap's own record clock. The same beat reaching both lands
+ // on two different seconds, which no same-second de-dup can collapse — the signature every
+ // affected night prints as `crossSecondOverCount`.
+ if !streams.rr.isEmpty {
+ let nowSec = now()
+ if RrEmissionStats.shouldEmitLiveCensus(lastEmitSec: lastRealtimeRrCensusSec, nowSec: nowSec) {
+ lastRealtimeRrCensusSec = nowSec
+ let census = RrEmissionStats.compute(streams.rr.map { (ts: $0.ts, rrMs: $0.rrMs) })
+ log?(RrEmissionStats.logLine(path: "live-realtime", offered: streams.rr.count,
+ inserted: nil, census))
+ }
+ }
do {
- try await store.insert(streams, deviceId: deviceId) // DECODED FIRST (durable)
+ let inserted = try await store.insert(streams, deviceId: deviceId) // DECODED FIRST (durable)
+ realtimeInsertFailures = 0
+ onBanked?(inserted)
} catch {
// Re-buffer at the front so these frames (and their parses) are retried on the next cadence.
buffer.insert(contentsOf: batch, at: 0)
+ // Swallowing this made the census above read like success: a store rejecting everything still
+ // reported what was OFFERED, with nothing to say none of it landed.
+ realtimeInsertFailures += 1
+ let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
+ if LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: lastRealtimeInsertFailureLogMs,
+ nowMs: nowMs) {
+ lastRealtimeInsertFailureLogMs = nowMs
+ log?(LivePersistTrace.liveInsertFailedLine(
+ transport: "live-realtime", errorName: String(describing: type(of: error)),
+ message: error.localizedDescription, hrFrames: streams.hr.count,
+ rrFrames: streams.rr.count, consecutiveFailures: realtimeInsertFailures))
+ }
return
}
// Reset only after a successful insert so the interval trigger keeps firing if
@@ -203,25 +281,78 @@ final class Collector {
/// Buffer one standard Heart-Rate-Measurement reading. No clock correlation needed —
/// these carry a wall-clock `ts` directly. Auto-flushes ~every 30 readings (~30s).
- func ingestStandardHR(hr: Int, rr: [Int], at ts: Int) {
- if hr >= 30, hr <= 220 { stdHR.append(HRSample(ts: ts, bpm: hr)) }
- for r in rr where r >= 250 && r <= 3000 { stdRR.append(RRInterval(ts: ts, rrMs: r)) }
- if stdHR.count + stdRR.count >= 30 {
- Task { @MainActor in await self.flushStandardHR() }
+ func ingestStandardHR(hr: Int, rr: [Int], contact: StandardHRContact? = nil, at ts: Int) {
+ let acceptedHR = (30...220).contains(hr) ? 1 : 0
+ let acceptedRR = rr.filter { (250...3000).contains($0) }
+ if acceptedHR == 1 { stdHR.append(HRSample(ts: ts, bpm: hr)) }
+ stdRR.append(contentsOf: acceptedRR.map { RRInterval(ts: ts, rrMs: $0) })
+ // Only the CHANGES. Advanced here rather than at flush because the event travels in the buffer
+ // until it persists: a failed insert re-inserts it at the front, so nothing has to be unwound.
+ if let contact, StandardHRMapping.shouldRecordContact(previous: lastStdContact, current: contact) {
+ lastStdContact = contact
+ stdContact.append(contentsOf: StandardHRMapping.samples(
+ fromHR: hr, rr: [], contact: contact, at: ts
+ ).events)
+ }
+ log?(LivePersistTrace.standardHRHostReceivedLine(
+ hostUnixSeconds: ts,
+ acceptedHRRows: acceptedHR, acceptedRRRows: acceptedRR.count,
+ rejectedHRRows: 1 - acceptedHR, rejectedRRRows: rr.count - acceptedRR.count,
+ pendingHRRows: stdHR.count, pendingRRRows: stdRR.count))
+ if stdHR.count + stdRR.count + stdContact.count >= 30 {
+ Task { @MainActor in await self.flushStandardHR(reason: .cadence) }
}
}
- /// Persist the buffered standard HR/RR. Re-buffers on failure so nothing is lost.
- func flushStandardHR() async {
- guard !stdHR.isEmpty || !stdRR.isEmpty else { return }
- let hr = stdHR, rr = stdRR
+ /// Persist the buffered standard HR/RR/contact. Re-buffers on failure so nothing is lost.
+ func flushStandardHR(reason: LivePersistTrace.StandardHRFlushReason = .explicit) async {
+ guard !stdHR.isEmpty || !stdRR.isEmpty || !stdContact.isEmpty else { return }
+ let hr = stdHR, rr = stdRR, contact = stdContact
stdHR.removeAll(keepingCapacity: true)
stdRR.removeAll(keepingCapacity: true)
+ stdContact.removeAll(keepingCapacity: true)
+ log?(LivePersistTrace.standardHRFlushAttemptLine(
+ reason: reason, offeredHRRows: hr.count, offeredRRRows: rr.count))
+ // #1118: census this batch BEFORE it is stored, exactly as the historical path does, so a strap
+ // log carries one `ratioRep` per transport. If each transport reports ~1.0 while the stored night
+ // reads 2.77, the over-count is the UNION of the transports and no single decoder is at fault —
+ // which is the question this instrumentation exists to settle.
+ if !rr.isEmpty {
+ let nowSec = now()
+ if RrEmissionStats.shouldEmitLiveCensus(lastEmitSec: lastStdRrCensusSec, nowSec: nowSec) {
+ lastStdRrCensusSec = nowSec
+ let census = RrEmissionStats.compute(rr.map { (ts: $0.ts, rrMs: $0.rrMs) })
+ // `inserted` is NIL, not echoed from `offered`: the store's conflict key decides that and
+ // this census runs before the insert. The line renders `inserted=n/a`.
+ log?(RrEmissionStats.logLine(path: "live-standard", offered: rr.count,
+ inserted: nil, census))
+ }
+ }
do {
- try await store.insert(Streams(hr: hr, rr: rr), deviceId: deviceId)
+ let inserted = try await store.insert(Streams(hr: hr, rr: rr, events: contact), deviceId: deviceId)
+ stdInsertFailures = 0
+ onBanked?(inserted)
+ log?(LivePersistTrace.standardHRFlushSucceededLine(
+ reason: reason, offeredHRRows: hr.count, offeredRRRows: rr.count,
+ insertedHRRows: inserted.hr, insertedRRRows: inserted.rr))
} catch {
stdHR.insert(contentsOf: hr, at: 0)
stdRR.insert(contentsOf: rr, at: 0)
+ stdContact.insert(contentsOf: contact, at: 0)
+ stdInsertFailures += 1
+ log?(LivePersistTrace.standardHRRebufferedForRetryLine(
+ reason: reason, attemptedHRRows: hr.count, attemptedRRRows: rr.count,
+ pendingHRRows: stdHR.count, pendingRRRows: stdRR.count,
+ consecutiveFailures: stdInsertFailures))
+ let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
+ if LivePersistTrace.shouldEmitLiveInsertFailure(lastEmitMs: lastStdInsertFailureLogMs,
+ nowMs: nowMs) {
+ lastStdInsertFailureLogMs = nowMs
+ log?(LivePersistTrace.liveInsertFailedLine(
+ transport: "live-standard", errorName: String(describing: type(of: error)),
+ message: error.localizedDescription, hrFrames: hr.count, rrFrames: rr.count,
+ consecutiveFailures: stdInsertFailures))
+ }
}
}
@@ -233,6 +364,11 @@ final class Collector {
rawCapture.open(at: monotonic(), duration: seconds)
}
+ private func recordGroundTruthImu(_ frame: [UInt8]) {
+ _ = ImuSessionFileStore.shared.append(deviceId: deviceId, frame: frame,
+ receivedAtMs: Int64(Date().timeIntervalSince1970 * 1_000))
+ }
+
/// Flush WHILE the window is still active so the just-captured frames get persisted as raw,
/// THEN close the window.
func endRawCapture() async {
diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift
new file mode 100644
index 0000000000..26cf0b254f
--- /dev/null
+++ b/Strand/Collect/ImuSessionFileStore.swift
@@ -0,0 +1,189 @@
+import Compression
+import Foundation
+import WhoopProtocol
+
+/// Canonical decoded 100 Hz IMU storage: UTC half-hour files with appendable 30-second zlib blocks.
+@MainActor
+final class ImuSessionFileStore {
+ struct Stats { let bytes: Int64; let coveredSeconds: Int; let firstTs: Int64? }
+ struct ExportSegment { let name: String; let data: Data; let startTs, endTs: Int; let sampleCount: Int }
+ private struct Window: Codable { let id, deviceId: String; let from: Int64; var to: Int64? }
+ private struct Record { let ts, receivedAtMs: Int64; let columns: [Int16] }
+ static let shared = ImuSessionFileStore()
+ static let sampleRate = 100, axes = 6, blockSeconds = 30
+ static let segmentSeconds: Int64 = 30 * 60
+ private static let payloadBytes = sampleRate * axes * 2
+ private static let magic = Data("NOOPIMU2".utf8)
+ private let defaults = UserDefaults.standard
+ private let key = "imu-session-windows-v1"
+ private let directory: URL
+ private var seen: [String: Set] = [:]
+ private var pending: [String: [Record]] = [:]
+
+ private init() {
+ let fm = FileManager.default
+ let base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask,
+ appropriateFor: nil, create: true)) ?? fm.temporaryDirectory
+ directory = base.appendingPathComponent("OpenWhoop/RawImuSessions", isDirectory: true)
+ try? fm.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+
+ func start(id: String, deviceId: String, fromMs: Int64) {
+ var value = windows().filter { $0.id != id }
+ value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: nil)); save(value)
+ try? FileManager.default.createDirectory(at: sessionDirectory(id), withIntermediateDirectories: true)
+ }
+ func complete(id: String, toMs: Int64) {
+ flushSession(id); var value = windows()
+ if let index = value.firstIndex(where: { $0.id == id }) { value[index].to = toMs / 1_000; save(value) }
+ }
+ func register(id: String, deviceId: String, fromMs: Int64, toMs: Int64) {
+ var value = windows().filter { $0.id != id }
+ value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: toMs / 1_000)); save(value)
+ try? FileManager.default.createDirectory(at: sessionDirectory(id), withIntermediateDirectories: true)
+ }
+ func remove(id: String) {
+ pending.keys.filter { $0.hasPrefix("\(id)/") }.forEach { pending[$0] = nil }
+ seen.keys.filter { $0.hasPrefix(sessionDirectory(id).path) }.forEach { seen[$0] = nil }
+ save(windows().filter { $0.id != id })
+ }
+ func prepareForRead(_ id: String) { flushSession(id) }
+
+ func deleteFiles(_ id: String, removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }) -> Bool {
+ flushSession(id); let dir = sessionDirectory(id)
+ guard FileManager.default.fileExists(atPath: dir.path) else { return true }
+ do { try removeItem(dir); return true } catch { return false }
+ }
+
+ func deleteDevice(_ deviceId: String) -> Bool {
+ let owned = windows().filter { $0.deviceId == deviceId }.map(\.id)
+ guard owned.allSatisfy({ deleteFiles($0) }) else { return false }
+ owned.forEach { remove(id: $0) }; return true
+ }
+
+ @discardableResult
+ func append(deviceId: String, frame: [UInt8], receivedAtMs: Int64) -> Int {
+ guard let ts = Whoop5RawImu.baseTs(frame), let columns = Whoop5RawImu.rawColumns(frame) else { return 0 }
+ var count = 0
+ for window in windows() where window.deviceId == deviceId && Int64(ts) >= window.from
+ && (window.to == nil || Int64(ts) <= window.to!) {
+ let bucket = Self.bucketStart(Int64(ts)), url = segmentFile(window.id, bucket)
+ var timestamps = seen[url.path] ?? scan(url)
+ guard timestamps.insert(Int64(ts)).inserted else { continue }
+ seen[url.path] = timestamps
+ let pendingKey = "\(window.id)/\(bucket)"
+ pending[pendingKey, default: []].append(Record(ts: Int64(ts), receivedAtMs: receivedAtMs, columns: columns))
+ if pending[pendingKey]!.count >= Self.blockSeconds { flushKey(pendingKey) }
+ count += 1
+ }
+ return count
+ }
+
+ func stats(_ id: String, from: Int, to: Int) -> Stats {
+ let records = readRecords(id, from: from, to: to, includePending: true)
+ let disk = segmentFiles(id).reduce(Int64(0)) { value, url in
+ value + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
+ }
+ let queued = pending.filter { $0.key.hasPrefix("\(id)/") }.values.flatMap { $0 }
+ .reduce(Int64(0)) { $0 + Int64($1.columns.count * 2 + 20) }
+ return Stats(bytes: disk + queued, coveredSeconds: Set(records.map(\.ts)).count,
+ firstTs: records.map(\.ts).min())
+ }
+
+ func exportSegments(_ id: String, from: Int, to: Int) -> [ExportSegment] {
+ flushSession(id)
+ let records = Dictionary(grouping: readRecords(id, from: from, to: to, includePending: false), by: \.ts)
+ .compactMap { $0.value.first }.sorted { $0.ts < $1.ts }
+ return Dictionary(grouping: records) { Self.bucketStart($0.ts) }.sorted { $0.key < $1.key }.compactMap { element in
+ let (bucket, rows) = element
+ guard let first = rows.first, let last = rows.last else { return nil }
+ return ExportSegment(name: "imu-\(Self.utcName(bucket)).imus", data: encode(bucket: bucket, records: rows),
+ startTs: Int(first.ts), endTs: Int(last.ts), sampleCount: rows.count * Self.sampleRate)
+ }
+ }
+
+ private func readRecords(_ id: String, from: Int, to: Int, includePending: Bool) -> [Record] {
+ var rows = segmentFiles(id).flatMap { decode((try? Data(contentsOf: $0)) ?? Data()) }
+ .filter { $0.ts >= from && $0.ts <= to }
+ if includePending { rows += pending.filter { $0.key.hasPrefix("\(id)/") }.values.flatMap { $0 }
+ .filter { $0.ts >= from && $0.ts <= to } }
+ return rows
+ }
+
+ private func encode(bucket: Int64, records: [Record]) -> Data {
+ var result = header(bucket)
+ for start in stride(from: 0, to: records.count, by: Self.blockSeconds) {
+ result.append(block(Array(records[start.. [Record] {
+ guard data.count >= 24, data.prefix(8) == Self.magic else { return [] }
+ let bytes = [UInt8](data); var offset = 8
+ _ = Int64(bigEndianBytes: bytes, at: offset); offset += 8
+ guard int32(bytes, offset) == Self.sampleRate, int32(bytes, offset + 4) == Self.axes else { return [] }
+ offset += 8; var result: [Record] = []
+ while offset + 12 <= bytes.count {
+ let count = int32(bytes, offset), rawSize = int32(bytes, offset + 4), compressedSize = int32(bytes, offset + 8)
+ offset += 12
+ guard count > 0, count <= Self.blockSeconds, rawSize > 0, compressedSize > 0,
+ offset + compressedSize <= bytes.count,
+ let raw = inflate(Data(bytes[offset.. Data {
+ var data = Self.magic; data.appendBigEndian(bucket); data.appendBigEndian(Int32(Self.sampleRate)); data.appendBigEndian(Int32(Self.axes)); return data
+ }
+ private func block(_ records: [Record]) -> Data {
+ var raw = Data()
+ for record in records {
+ raw.appendBigEndian(record.ts); raw.appendBigEndian(record.receivedAtMs); raw.appendBigEndian(Int32(Self.payloadBytes))
+ for value in record.columns { raw.append(UInt8(truncatingIfNeeded: value)); raw.append(UInt8(truncatingIfNeeded: value >> 8)) }
+ }
+ guard let compressed = deflate(raw) else { return Data() }
+ var data = Data(); data.appendBigEndian(Int32(records.count)); data.appendBigEndian(Int32(raw.count))
+ data.appendBigEndian(Int32(compressed.count)); data.append(compressed); return data
+ }
+
+ private func windows() -> [Window] { defaults.data(forKey: key).flatMap { try? JSONDecoder().decode([Window].self, from: $0) } ?? [] }
+ private func save(_ value: [Window]) { defaults.set(try? JSONEncoder().encode(value), forKey: key) }
+ private func sessionDirectory(_ id: String) -> URL { directory.appendingPathComponent(id, isDirectory: true) }
+ private func segmentFile(_ id: String, _ bucket: Int64) -> URL { sessionDirectory(id).appendingPathComponent("imu-\(Self.utcName(bucket)).imus") }
+ private func segmentFiles(_ id: String) -> [URL] { ((try? FileManager.default.contentsOfDirectory(at: sessionDirectory(id), includingPropertiesForKeys: nil)) ?? []).filter { $0.pathExtension == "imus" }.sorted { $0.lastPathComponent < $1.lastPathComponent } }
+ private func scan(_ url: URL) -> Set { Set(decode((try? Data(contentsOf: url)) ?? Data()).map(\.ts)) }
+ private static func bucketStart(_ ts: Int64) -> Int64 { ts >= 0 ? ts / segmentSeconds * segmentSeconds : ((ts - segmentSeconds + 1) / segmentSeconds) * segmentSeconds }
+ private static func utcName(_ ts: Int64) -> String { let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = TimeZone(secondsFromGMT: 0); f.dateFormat = "yyyyMMdd'T'HHmmss'Z'"; return f.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) }
+ private func int32(_ bytes: [UInt8], _ offset: Int) -> Int { Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16 | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]) }
+ private func deflate(_ input: Data) -> Data? { let capacity = input.count + 128; var output = Data(count: capacity); let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in compression_encode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, capacity, src.bindMemory(to: UInt8.self).baseAddress!, input.count, nil, COMPRESSION_ZLIB) } }; guard written > 0 else { return nil }; output.count = written; return output }
+ private func inflate(_ input: Data, size: Int) -> Data? { var output = Data(count: size); let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in compression_decode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, size, src.bindMemory(to: UInt8.self).baseAddress!, input.count, nil, COMPRESSION_ZLIB) } }; return written == size ? output : nil }
+}
+
+private extension Int64 { init(bigEndianBytes bytes: [UInt8], at offset: Int) { self = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int64($1) } } }
+private extension Data { mutating func appendBigEndian(_ value: T) { var value = value.bigEndian; Swift.withUnsafeBytes(of: &value) { append(contentsOf: $0) } } }
diff --git a/Strand/Collect/RawCaptureWindow.swift b/Strand/Collect/RawCaptureWindow.swift
index 336273ff4a..833868f325 100644
--- a/Strand/Collect/RawCaptureWindow.swift
+++ b/Strand/Collect/RawCaptureWindow.swift
@@ -5,7 +5,9 @@ import Foundation
/// auto-expires the window so a missed stop callback can't leak raw forever.
struct RawCaptureWindow {
static let minSeconds: TimeInterval = 1
- static let maxSeconds: TimeInterval = 300
+ // A manually stopped research session may legitimately last for hours. It is still bounded so an
+ // interrupted UI/process cannot accidentally turn the opt-in capture into permanent 24/7 logging.
+ static let maxSeconds: TimeInterval = 24 * 60 * 60
static func clamp(_ s: TimeInterval) -> TimeInterval { min(max(s, minSeconds), maxSeconds) }
private var deadline: TimeInterval? // monotonic deadline; nil = inactive
diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift
new file mode 100644
index 0000000000..5e962858d3
--- /dev/null
+++ b/Strand/Collect/RawDataSessionStore.swift
@@ -0,0 +1,237 @@
+import Combine
+import Foundation
+import WhoopStore
+
+/// Persistent, user-controlled 5/MG capture sessions. This is the iOS/macOS twin of Android's
+/// GroundTruthCollector: it records bounded raw-data windows with optional comments.
+@MainActor
+final class RawDataSessionStore: ObservableObject {
+ struct Event: Codable, Identifiable, Equatable {
+ var id = UUID()
+ var atMs: Int64
+ let kind: String
+ var markerId: String?
+ var markerType: String?
+ var text: String?
+ }
+
+ struct Session: Codable, Identifiable, Equatable {
+ let id: String
+ let deviceId: String
+ let startedAtMs: Int64
+ var endedAtMs: Int64?
+ var capturedStartedAtMs: Int64?
+ var capturedEndedAtMs: Int64?
+ var comment: String
+ var exported: Bool
+ var lastExportedAtMs: Int64?
+ var events: [Event]
+
+ var active: Bool { endedAtMs == nil }
+ }
+
+ @Published private(set) var sessions: [Session] = []
+ var active: Session? { sessions.first(where: \.active) }
+
+ private let directory: URL
+ private let encoder: JSONEncoder
+ private let decoder = JSONDecoder()
+
+ init(directory override: URL? = nil, fileManager: FileManager = .default) {
+ let base = (try? fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask,
+ appropriateFor: nil, create: true)) ?? fileManager.temporaryDirectory
+ directory = override ?? base.appendingPathComponent("OpenWhoop/RawDataSessions", isDirectory: true)
+ try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ reload()
+ }
+
+ func reload() {
+ let urls = (try? FileManager.default.contentsOfDirectory(at: directory,
+ includingPropertiesForKeys: nil)) ?? []
+ sessions = urls.filter { $0.pathExtension == "json" }
+ .compactMap { try? Data(contentsOf: $0) }
+ .compactMap { try? decoder.decode(Session.self, from: $0) }
+ .sorted { $0.startedAtMs > $1.startedAtMs }
+ }
+
+ @discardableResult
+ func start(deviceId: String, now: Date = Date()) -> Session? {
+ guard active == nil else { return nil }
+ let millis = Int64(now.timeIntervalSince1970 * 1_000)
+ let started = Event(atMs: millis, kind: "start")
+ let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis,
+ endedAtMs: nil, capturedStartedAtMs: millis, capturedEndedAtMs: nil,
+ comment: "", exported: false, lastExportedAtMs: nil, events: [started])
+ sessions.insert(session, at: 0)
+ persist(session)
+ ImuSessionFileStore.shared.start(id: session.id, deviceId: deviceId, fromMs: millis)
+ return session
+ }
+
+ func stop(now: Date = Date()) {
+ let activeId = active?.id
+ mutateActive { session in
+ let millis = Int64(now.timeIntervalSince1970 * 1_000)
+ session.endedAtMs = millis
+ session.capturedEndedAtMs = millis
+ session.events.append(Event(atMs: millis, kind: "stop"))
+ }
+ if let activeId { ImuSessionFileStore.shared.complete(id: activeId, toMs: Int64(now.timeIntervalSince1970 * 1_000)) }
+ }
+
+ @discardableResult
+ func createHistorical(deviceId: String, from: Date, to: Date) -> Session? {
+ let fromMs = Int64(from.timeIntervalSince1970 * 1_000)
+ let toMs = Int64(to.timeIntervalSince1970 * 1_000)
+ guard validRange(fromMs, toMs) else { return nil }
+ let id = String(Int64(Date().timeIntervalSince1970 * 1_000))
+ let events = [Event(atMs: fromMs, kind: "start"), Event(atMs: toMs, kind: "stop")]
+ let session = Session(id: id, deviceId: deviceId, startedAtMs: fromMs, endedAtMs: toMs,
+ capturedStartedAtMs: nil, capturedEndedAtMs: nil, comment: "",
+ exported: false, lastExportedAtMs: nil, events: events)
+ sessions.insert(session, at: 0); persist(session)
+ ImuSessionFileStore.shared.register(id: id, deviceId: deviceId, fromMs: fromMs, toMs: toMs)
+ return session
+ }
+
+ func setRange(sessionId: String, from: Date, to: Date) {
+ let fromMs = Int64(from.timeIntervalSince1970 * 1_000)
+ let toMs = Int64(to.timeIntervalSince1970 * 1_000)
+ guard validRange(fromMs, toMs) else { return }
+ mutate(sessionId) { session in
+ guard !session.active else { return }
+ session = Session(id: session.id, deviceId: session.deviceId, startedAtMs: fromMs,
+ endedAtMs: toMs, capturedStartedAtMs: session.capturedStartedAtMs,
+ capturedEndedAtMs: session.capturedEndedAtMs, comment: session.comment,
+ exported: false, lastExportedAtMs: nil, events: session.events)
+ }
+ if let session = sessions.first(where: { $0.id == sessionId }) {
+ ImuSessionFileStore.shared.register(id: sessionId, deviceId: session.deviceId, fromMs: fromMs, toMs: toMs)
+ }
+ }
+
+ func setComment(_ comment: String, sessionId: String) {
+ mutate(sessionId) { $0.comment = String(comment.prefix(4_000)) }
+ }
+
+ @discardableResult
+ func addMarker(sessionId: String, at: Date, type: String, text: String) -> Event? {
+ guard Self.markerTypes.contains(type) else { return nil }
+ var added: Event?
+ mutate(sessionId) { session in
+ let atMs = Self.clamp(Int64(at.timeIntervalSince1970 * 1_000), to: session)
+ let marker = Event(atMs: atMs, kind: "marker", markerId: UUID().uuidString,
+ markerType: type, text: String(text.prefix(500)))
+ session.events.append(marker)
+ added = marker
+ }
+ return added
+ }
+
+ func updateMarker(sessionId: String, markerId: String, at: Date, type: String, text: String) {
+ guard Self.markerTypes.contains(type) else { return }
+ mutate(sessionId) { session in
+ guard let index = session.events.firstIndex(where: { $0.markerId == markerId }) else { return }
+ session.events[index].atMs = Self.clamp(Int64(at.timeIntervalSince1970 * 1_000), to: session)
+ session.events[index].markerType = type
+ session.events[index].text = String(text.prefix(500))
+ }
+ }
+
+ func deleteMarker(sessionId: String, markerId: String) {
+ mutate(sessionId) { $0.events.removeAll { $0.kind == "marker" && $0.markerId == markerId } }
+ }
+
+ func markExported(_ sessionId: String, now: Date = Date()) {
+ mutate(sessionId) {
+ $0.exported = true
+ $0.lastExportedAtMs = Int64(now.timeIntervalSince1970 * 1_000)
+ }
+ }
+
+ @discardableResult
+ func removeMetadata(_ sessionId: String,
+ removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }) -> Bool {
+ guard sessions.first(where: { $0.id == sessionId })?.active == false else { return false }
+ guard ImuSessionFileStore.shared.deleteFiles(sessionId, removeItem: removeItem) else { return false }
+ ImuSessionFileStore.shared.remove(id: sessionId)
+ do {
+ let metadata = file(sessionId)
+ if FileManager.default.fileExists(atPath: metadata.path) { try removeItem(metadata) }
+ } catch { return false }
+ sessions.removeAll { $0.id == sessionId }
+ return true
+ }
+
+ private func mutateActive(_ body: (inout Session) -> Void) {
+ guard let index = sessions.firstIndex(where: \.active) else { return }
+ body(&sessions[index])
+ persist(sessions[index])
+ }
+
+ private func mutate(_ id: String, _ body: (inout Session) -> Void) {
+ guard let index = sessions.firstIndex(where: { $0.id == id }) else { return }
+ body(&sessions[index])
+ persist(sessions[index])
+ }
+
+ private func persist(_ session: Session) {
+ guard let data = try? encoder.encode(session) else { return }
+ try? data.write(to: file(session.id), options: .atomic)
+ }
+
+ private func file(_ id: String) -> URL { directory.appendingPathComponent("session-\(id).json") }
+
+ private func validRange(_ from: Int64, _ to: Int64) -> Bool {
+ from > 0 && from <= to && to - from <= 7 * 24 * 60 * 60 * 1_000
+ }
+
+ static let markerTypes = ["moment", "start", "end", "issue"]
+
+ private static func clamp(_ atMs: Int64, to session: Session) -> Int64 {
+ min(max(atMs, session.startedAtMs), session.endedAtMs ?? Int64(Date().timeIntervalSince1970 * 1_000))
+ }
+
+ func exportEntries(for session: Session, sensorAvailable: Bool = false) -> [FileExport.BundleEntry] {
+ var meta: [String: Any] = [
+ "schema_version": 3, "capture_kind": "whoop_5mg_raw_data",
+ "session_id": session.id, "device_id": session.deviceId,
+ "started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs,
+ "comment": session.comment,
+ "sensor_export_available": sensorAvailable,
+ "device_family": "iOS",
+ "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?",
+ "markers": session.events.filter {
+ $0.kind == "marker" && $0.atMs >= session.startedAtMs
+ && $0.atMs <= (session.endedAtMs ?? session.startedAtMs)
+ }.map { marker in
+ ["id": marker.markerId ?? "", "at_ms": marker.atMs,
+ "type": marker.markerType ?? "moment", "text": marker.text ?? ""] as [String: Any]
+ },
+ ]
+ if let value = session.capturedStartedAtMs { meta["captured_started_at_ms"] = value }
+ if let value = session.capturedEndedAtMs { meta["captured_ended_at_ms"] = value }
+ let metaData = (try? JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted, .sortedKeys])) ?? Data()
+ let endMs = session.endedAtMs ?? session.startedAtMs
+ let publicEvents = [Event(atMs: session.startedAtMs, kind: "start")]
+ + session.events.filter {
+ $0.kind == "marker" && $0.atMs >= session.startedAtMs && $0.atMs <= endMs
+ }.sorted { $0.atMs < $1.atMs }
+ + [Event(atMs: endMs, kind: "stop")]
+ let eventData = publicEvents.compactMap { event -> Data? in
+ var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind]
+ if event.kind == "marker" {
+ if let value = event.markerId { row["marker_id"] = value }
+ if let value = event.markerType { row["marker_type"] = value }
+ if let value = event.text { row["text"] = value }
+ }
+ return try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys])
+ }.reduce(into: Data()) { $0.append($1); $0.append(0x0A) }
+ return [
+ .init(name: "meta.json", data: metaData),
+ .init(name: "events.jsonl", data: eventData),
+ ]
+ }
+}
diff --git a/Strand/Data/BackupSync.swift b/Strand/Data/BackupSync.swift
index b485d7caee..c04b8629bc 100644
--- a/Strand/Data/BackupSync.swift
+++ b/Strand/Data/BackupSync.swift
@@ -202,11 +202,53 @@ enum FolderBackup {
return dir
}
+ /// Whether a folder path sits in iCloud. The SINGLE definition of that rule — `restoreListHealth`
+ /// reports the same signal to diagnostics, and a screen that disagreed with the diagnostics dump
+ /// about where the backups are would make #278-style triage worse, not better.
+ ///
+ /// Ubiquity containers live under `Mobile Documents`, which covers both iCloud Drive proper
+ /// (`com~apple~CloudDocs`) and per-app containers. Pure and path-only, so it is testable without a
+ /// picker, a bookmark or a device.
+ static func isICloudPath(_ path: String) -> Bool { path.contains("Mobile Documents") }
+
+ /// The tail of a folder path, for display: `"NOOP › 2026"`.
+ ///
+ /// The bare last component is not enough to identify a folder — a "Backups" folder in iCloud Drive
+ /// and the #52 internal fallback's `Documents/Backups` render identically, which is exactly the
+ /// confusion this line is meant to settle. The FULL path is not shown either: on iOS it is a long
+ /// sandbox UUID and on macOS it carries the user's home directory, neither of which helps.
+ ///
+ /// For an iCloud path everything up to and including the sync root (`com~apple~CloudDocs`, or a
+ /// per-app `iCloud~…` container) is dropped, since [folderLabel] already names iCloud Drive and the
+ /// raw tilde form means nothing to a reader.
+ static func folderTrail(path: String, maxComponents: Int = 2) -> String {
+ var parts = path.split(separator: "/").map(String.init).filter { !$0.isEmpty }
+ // Slice AFTER the sync root rather than filtering it out: on macOS an iCloud path is
+ // `~/Library/Mobile Documents/com~apple~CloudDocs/NOOP`, so merely dropping the tilde component
+ // leaves "Mobile Documents › NOOP" — plumbing the reader should never see.
+ if let i = parts.lastIndex(where: {
+ $0 == "com~apple~CloudDocs" || $0.hasPrefix("iCloud~") || $0 == "Mobile Documents"
+ }) { parts = Array(parts[(i + 1)...]) }
+ return parts.suffix(max(1, maxComponents)).joined(separator: " › ")
+ }
+
/// A short, human label for the chosen folder, or nil if none chosen. The internal fallback gets a
/// friendly name instead of the raw "Backups" path component.
+ ///
+ /// Names iCloud explicitly (#52): the picker can already point anywhere, iCloud Drive included, so
+ /// the gap was never the choosing — it was that the result was unidentifiable afterwards. A user on
+ /// the internal fallback saw a bare "Backups" and could not tell their backups were on-device only.
static func folderLabel() -> String? {
if useInternalFolder { return String(localized: "NOOP (in Files)") }
- return resolveFolder()?.lastPathComponent
+ guard let path = resolveFolder()?.path else { return nil }
+ let trail = folderTrail(path: path)
+ if isICloudPath(path) {
+ // Picking the iCloud Drive root itself leaves no trail; naming the place alone beats an
+ // empty separator dangling off the end.
+ return trail.isEmpty ? String(localized: "iCloud Drive")
+ : String(localized: "iCloud Drive › \(trail)")
+ }
+ return trail.isEmpty ? (path.split(separator: "/").last.map(String.init) ?? path) : trail
}
// MARK: - Security-scoped bookmark
@@ -265,7 +307,13 @@ enum FolderBackup {
let nowMs = Int(Date().timeIntervalSince1970 * 1000.0)
let dest = folder.appendingPathComponent(BackupSync.snapshotName(nowMs))
- guard case .exported = await DataBackup.writeBackup(checkpoint: checkpoint, to: dest) else { return false }
+ // #1807: an oversize write still WROTE the file, so it is a success here. The folder sync has
+ // nowhere to surface a warning and must not report failure for a backup that exists — the
+ // interactive export is where the user is told, because that is where they can act on it.
+ switch await DataBackup.writeBackup(checkpoint: checkpoint, to: dest) {
+ case .exported, .exportedOversize: break
+ default: return false
+ }
UserDefaults.standard.set(nowMs, forKey: lastKey)
prune(in: folder)
@@ -304,8 +352,9 @@ enum FolderBackup {
let raw = (try? FileManager.default.contentsOfDirectory(atPath: folder.path))?.count ?? 0
// Report iCloud-vs-local (the actual #278 triage signal — undownloaded placeholders live in the
// iCloud container) WITHOUT the folder's user-chosen name, which redactPii can't scrub and the
- // iOS twin never exposes. The iCloud ubiquity container path contains "Mobile Documents".
- let isICloud = folder.path.contains("Mobile Documents")
+ // iOS twin never exposes. Shares `isICloudPath` with the label the user sees, so the screen and
+ // this dump can never disagree about where the backups are.
+ let isICloud = isICloudPath(folder.path)
return (isICloud, raw, listSnapshots().count)
}
diff --git a/Strand/Data/DataBackup.swift b/Strand/Data/DataBackup.swift
index a37bfada2c..8b56e96aa4 100644
--- a/Strand/Data/DataBackup.swift
+++ b/Strand/Data/DataBackup.swift
@@ -27,7 +27,12 @@ import ZIPFoundation
/// security-scoped access on the panel-returned URLs. Every path is best-effort — failures surface
/// as a `.failure` result and never crash.
enum DataBackup {
- private static let maxBackupSQLiteBytes: Int64 = 2_147_483_648
+ /// Uncompressed ceiling for the SQLite entry, enforced while EXTRACTING (see `extractBackupZip`).
+ /// Deliberately measured on the decompressed stream: this is a zip-bomb guard, and a zip bomb is by
+ /// definition small compressed and enormous expanded, so a cap on the archive's own size would be
+ /// trivially defeated. Compressing harder cannot help a user past it — the backup is already
+ /// `.deflate`d and the count is of bytes landing on disk. (#1807)
+ static let maxBackupSQLiteBytes: Int64 = 2_147_483_648
private static let maxBackupSettingsBytes: Int64 = 1_048_576
// MARK: - Result
@@ -35,11 +40,21 @@ enum DataBackup {
enum BackupResult {
/// Export wrote the backup to `url`.
case exported(URL)
+ /// Export wrote the backup, but the database is larger than [maxBackupSQLiteBytes], the ceiling
+ /// the restore path enforces. The file is valid and worth keeping — restoring it just needs the
+ /// user to confirm once. Reported at EXPORT time because the alternative is finding out during a
+ /// restore, which is exactly when the original is gone. (#1807)
+ case exportedOversize(URL, bytes: Int64, limit: Int64)
/// Import succeeded; a relaunch is required for it to take effect. `sidecar` is where the
/// previous database was preserved, in case the user wants to roll back.
case imported(sidecar: URL)
/// The user dismissed the save/open panel — nothing happened, show nothing loud.
case cancelled
+ /// The restore stopped ONLY because an entry exceeds the size ceiling. Distinct from `failure`
+ /// so the caller can offer to go ahead anyway: the cap is a decompression guard against a hostile
+ /// archive, and a backup the user just picked out of their own files is a different threat model
+ /// than the one it defends against. (#1807)
+ case restoreTooLarge(name: String, limit: Int64)
/// Something went wrong; `message` is user-facing.
case failure(String)
}
@@ -94,7 +109,7 @@ enum DataBackup {
try await Task.detached(priority: .utility) {
try writeVerifiedBackupZip(dbURL: dbURL, to: dest, settingsJSON: currentSettingsJSON())
}.value
- return .exported(dest)
+ return exportOutcome(dest, dbURL: dbURL)
} catch {
return .failure(String(localized: "Export failed: \(error.localizedDescription)"))
}
@@ -113,7 +128,7 @@ enum DataBackup {
return .failure(String(localized: "Export failed: \(error.localizedDescription)"))
}
guard let dest = await DocumentPicker.export(staged) else { return .cancelled }
- return .exported(dest)
+ return exportOutcome(dest, dbURL: dbURL)
#endif
}
@@ -144,6 +159,17 @@ enum DataBackup {
/// when the original data may be long gone; failing loudly NOW is the honest move. The read-only
/// probe sits safely beside the app's open GRDB pool (WAL allows concurrent readers).
/// `writeBackupForTesting` deliberately bypasses this so tests can build damaged containers.
+ /// `.exported`, or `.exportedOversize` when the database is past the ceiling the restore path
+ /// enforces. Measured on the DATABASE, not the finished archive: the archive is `.deflate`d and the
+ /// cap the restore checks counts the DECOMPRESSED stream, so the zip's own size says nothing about
+ /// whether it can be read back. (#1807)
+ private static func exportOutcome(_ dest: URL, dbURL: URL) -> BackupResult {
+ let attrs = try? FileManager.default.attributesOfItem(atPath: dbURL.path)
+ guard let bytes = (attrs?[.size] as? NSNumber)?.int64Value,
+ bytes > maxBackupSQLiteBytes else { return .exported(dest) }
+ return .exportedOversize(dest, bytes: bytes, limit: maxBackupSQLiteBytes)
+ }
+
private static func writeVerifiedBackupZip(dbURL: URL, to dest: URL, settingsJSON: Data?) throws {
if let complaint = DatabaseIntegrity.quickCheckFailure(atPath: dbURL.path) {
throw ExportIntegrityFailure(complaint: complaint)
@@ -246,7 +272,7 @@ enum DataBackup {
let fm = FileManager.default
if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) }
try writeVerifiedBackupZip(dbURL: dbURL, to: dest, settingsJSON: currentSettingsJSON())
- return .exported(dest)
+ return exportOutcome(dest, dbURL: dbURL)
} catch {
return .failure(String(localized: "Backup failed: \(error.localizedDescription)"))
}
@@ -273,7 +299,7 @@ enum DataBackup {
/// siblings). The store stays open, so the swapped-in file only takes effect after a relaunch —
/// the caller informs the user.
@MainActor
- static func runImport() async -> BackupResult {
+ static func runImport(allowOversize: Bool = false) async -> BackupResult {
let dbPath: String
do { dbPath = try StorePaths.defaultDatabasePath() }
catch { return .failure(String(localized: "Couldn't locate the NOOP database. \(error.localizedDescription)")) }
@@ -305,7 +331,7 @@ enum DataBackup {
// stays valid because the surrounding function is still awaiting here. Only Sendable value
// types (URL, String) cross the hop; the result hops back to main for handleBackup.
return await Task.detached(priority: .utility) {
- restore(from: pickedSource, toDatabaseAt: dbPath)
+ restore(from: pickedSource, toDatabaseAt: dbPath, allowOversize: allowOversize)
}.value
}
@@ -330,7 +356,8 @@ enum DataBackup {
/// `settingsDefaults` is where a `settings.json` entry (#1000) is re-applied — injected for the
/// same reason as `dbPath` (tests use a suite-scoped UserDefaults, never the runner's real domain).
static func restore(from pickedSource: URL, toDatabaseAt dbPath: String,
- settingsDefaults: UserDefaults = .standard) -> BackupResult {
+ settingsDefaults: UserDefaults = .standard,
+ allowOversize: Bool = false) -> BackupResult {
// If the picked file is a .noopbak ZIP, extract the SQLite entry to a temp dir first.
// Legacy plain-SQLite files fall straight through. The extracted dir is cleaned up below.
let fm = FileManager.default
@@ -343,7 +370,15 @@ enum DataBackup {
do {
if fm.fileExists(atPath: tmpExtract.path) { try fm.removeItem(at: tmpExtract) }
try fm.createDirectory(at: tmpExtract, withIntermediateDirectories: true)
- try extractBackupZip(at: pickedSource, into: tmpExtract)
+ try extractBackupZip(at: pickedSource, into: tmpExtract, allowOversize: allowOversize)
+ } catch let err as BackupArchiveError {
+ try? fm.removeItem(at: tmpExtract)
+ // Reported as its own case, not a generic failure: this one is RECOVERABLE, and the caller
+ // is the only layer that can ask the user whether to go ahead. (#1807)
+ switch err {
+ case .entryTooLarge(let name):
+ return .restoreTooLarge(name: name, limit: maxBackupSQLiteBytes)
+ }
} catch {
try? fm.removeItem(at: tmpExtract)
return .failure(String(localized: "Couldn't open the backup archive: \(error.localizedDescription)"))
@@ -591,14 +626,15 @@ enum DataBackup {
/// Extract only the canonical entries from a `.noopbak` ZIP at `zipURL` into `destDir`.
/// Unknown files are ignored, and each accepted entry is streamed through an uncompressed-size cap
/// before it lands on disk.
- private static func extractBackupZip(at zipURL: URL, into destDir: URL) throws {
+ private static func extractBackupZip(at zipURL: URL, into destDir: URL,
+ allowOversize: Bool = false) throws {
let archive = try Archive(url: zipURL, accessMode: .read)
for entry in archive where entry.type == .file {
let name = (entry.path as NSString).lastPathComponent
let limit: Int64
switch name {
case backupEntryName:
- limit = maxBackupSQLiteBytes
+ limit = allowOversize ? Int64.max : maxBackupSQLiteBytes
case BackupSettings.entryName:
limit = maxBackupSettingsBytes
default:
diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift
new file mode 100644
index 0000000000..386e805c45
--- /dev/null
+++ b/Strand/Data/DayCycleIntelligenceIntegration.swift
@@ -0,0 +1,335 @@
+import Foundation
+import StrandAnalytics
+import WhoopProtocol
+import WhoopStore
+
+@MainActor enum DayCycleIntelligenceIntegration {
+ static let onsetKey = "day_cycle_onset_ts"
+ static let pageSize = 10_000
+
+ struct Night { let daily: DailyMetric; let sleeps: [CachedSleepSession]; let workouts: [ExerciseSession]; let owner: String }
+ struct SourcedMarker: Sendable { let deviceId: String; let point: MetricPoint }
+ struct BoundaryRecoveryReader {
+ let sleepSessions: (String, Int, Int) async throws -> [CachedSleepSession]
+ let markers: (String, String, String) async throws -> [MetricPoint]
+ }
+ enum MarkerUpdate {
+ case preserve
+ case replace(points: [SourcedMarker], sourceIds: [String])
+ }
+ struct Result {
+ let stepsByWakeDay: [String: Int]
+ let strainByWakeDay: [String: Double]
+ let caloriesByWakeDay: [String: Double]
+ let workoutCountByWakeDay: [String: Int]
+ let onsetByWakeDay: [String: Int]
+ let firstWakeDay: String?
+ let markerUpdate: MarkerUpdate
+ }
+ struct PersistedBoundary {
+ let boundary: PhysiologicalSteps.CycleBoundary
+ let wakeDay: String
+ let owner: String
+ let sleepContext: SleepSession
+ }
+ fileprivate struct CachedCycle {
+ let key: String; let count: SleepAwareStepCounter.Count
+ let pages: Int; let samples: Int; let evaluated: Bool
+ }
+ final class Cache {
+ fileprivate var cycles: [String: CachedCycle] = [:]
+ }
+ private static func computedId(_ owner: String) -> String { owner + "-noop" }
+
+ static func recover(candidates: [(owner: String, priority: Int)], reader: BoundaryRecoveryReader,
+ claimedDays: Set, windowStart: Int, now: Int,
+ offsetSec: Int, habitualMidsleepSec: Int?) async throws -> [PersistedBoundary] {
+ var claimed = claimedDays, output: [PersistedBoundary] = []
+ let fromDay = AnalyticsEngine.dayString(windowStart, offsetSec: offsetSec)
+ let toDay = AnalyticsEngine.dayString(now, offsetSec: offsetSec)
+ for candidate in candidates.sorted(by: {
+ $0.priority == $1.priority ? $0.owner < $1.owner : $0.priority < $1.priority
+ }) {
+ let source = computedId(candidate.owner)
+ let sessions = try await reader.sleepSessions(source, windowStart, now)
+ let points = try await reader.markers(source, fromDay, toDay)
+ let markers = Dictionary(points.compactMap { point -> (String, Int)? in
+ let value = Int(point.value)
+ return point.value == Double(value) ? (point.day, value) : nil
+ }, uniquingKeysWith: { first, _ in first })
+ let grouped = Dictionary(grouping: sessions) {
+ AnalyticsEngine.dayString($0.endTs, offsetSec: offsetSec)
+ }
+ for day in grouped.keys.sorted() where !claimed.contains(day) {
+ guard let marker = markers[day] else { continue }
+ let rows = grouped[day] ?? []
+ let blocks = rows.map { PhysiologicalSteps.SleepBlock(
+ onset: $0.startTs, end: $0.endTs, id: String($0.startTs),
+ editedOnset: $0.startTsAdjusted) }
+ guard let winner = PhysiologicalSteps.classifyForCycle(
+ blocks, offsetSec: offsetSec, habitualMidsleepSec: habitualMidsleepSec)
+ .first(where: { $0.kind == .mainSleep && $0.effectiveOnset == marker }),
+ let row = rows.first(where: {
+ String($0.startTs) == winner.id && $0.effectiveStartTs == marker
+ }) else { continue }
+ let context = SleepSession(start: row.effectiveStartTs, end: row.endTs,
+ efficiency: row.efficiency ?? 0, stages: AnalyticsEngine.decodeStages(row.stagesJSON),
+ restingHR: row.restingHr, avgHRV: row.avgHrv)
+ output.append(PersistedBoundary(boundary: .init(
+ sleepId: "persisted:\(candidate.owner):\(row.startTs)", onset: marker),
+ wakeDay: day, owner: candidate.owner, sleepContext: context))
+ claimed.insert(day)
+ }
+ }
+ return output
+ }
+
+ static func compute(nights: [Night], editedRows: [CachedSleepSession], store: WhoopStore,
+ candidates: [(owner: String, priority: Int)], physiologyOwners: [String],
+ workouts: [WorkoutRow], windowStart: Int,
+ now: Int, offsetSec: Int, habitualMidsleepSec: Int?, ticksPerStep: Double,
+ mode: DayCycleMode, cache: Cache,
+ profile: UserProfile, maxHROverride: Double?, effortMethod: StrainScorer.Method,
+ recoveryReader: BoundaryRecoveryReader? = nil,
+ trace: ((String) -> Void)? = nil) async -> Result {
+ guard mode == .sleepOnset else {
+ return Result(stepsByWakeDay: [:], strainByWakeDay: [:], caloriesByWakeDay: [:],
+ workoutCountByWakeDay: [:], onsetByWakeDay: [:], firstWakeDay: nil,
+ markerUpdate: .replace(points: [], sourceIds: Array(Set(
+ candidates.map { computedId($0.owner) })).sorted()))
+ }
+ let editsByDay = Dictionary(grouping: editedRows) {
+ AnalyticsEngine.dayString($0.endTs, offsetSec: offsetSec)
+ }
+ var boundaries: [PhysiologicalSteps.CycleBoundary] = []
+ var wakeDayById: [String: String] = [:], ownerById: [String: String] = [:]
+ var sleepContexts: [SleepSession] = []
+ for night in nights {
+ let dayEdits = editsByDay[night.daily.day] ?? []
+ let edits = Dictionary(dayEdits.map { ($0.startTs, $0) }, uniquingKeysWith: { first, _ in first })
+ let detectedStarts = Set(night.sleeps.map(\.startTs))
+ var blocks: [PhysiologicalSteps.SleepBlock] = []
+ for row in night.sleeps {
+ let edit = edits[row.startTs]
+ let start = edit?.effectiveStartTs ?? row.effectiveStartTs
+ let end = edit?.endTs ?? row.endTs
+ blocks.append(.init(onset: row.startTs, end: end, id: String(row.startTs), editedOnset: start))
+ sleepContexts.append(SleepSession(start: start, end: end,
+ efficiency: row.efficiency ?? 0, stages: AnalyticsEngine.decodeStages(row.stagesJSON),
+ restingHR: row.restingHr, avgHRV: row.avgHrv))
+ }
+ for edit in dayEdits where !detectedStarts.contains(edit.startTs) {
+ blocks.append(.init(onset: edit.startTs, end: edit.endTs,
+ id: "manual:\(edit.startTs)", editedOnset: edit.startTsAdjusted, kind: .nap))
+ sleepContexts.append(SleepSession(start: edit.effectiveStartTs, end: edit.endTs,
+ efficiency: 0, stages: [], restingHR: nil, avgHRV: nil))
+ }
+ let classified = PhysiologicalSteps.classifyForCycle(
+ blocks, offsetSec: offsetSec, habitualMidsleepSec: habitualMidsleepSec)
+ guard let winner = classified.filter({ $0.kind == .mainSleep })
+ .min(by: { $0.effectiveOnset < $1.effectiveOnset }), winner.effectiveOnset <= now else { continue }
+ boundaries.append(.init(sleepId: winner.id, onset: winner.effectiveOnset))
+ wakeDayById[winner.id] = night.daily.day; ownerById[winner.id] = night.owner
+ }
+
+ let recovered: [PersistedBoundary]
+ do {
+ let productionReader = BoundaryRecoveryReader(
+ sleepSessions: { source, from, to in
+ try await store.sleepSessions(deviceId: source, from: from, to: to, limit: 4_000)
+ },
+ markers: { source, fromDay, toDay in
+ try await store.metricSeries(
+ deviceId: source, key: onsetKey, from: fromDay, to: toDay)
+ })
+ recovered = try await recover(candidates: candidates,
+ reader: recoveryReader ?? productionReader,
+ claimedDays: Set(wakeDayById.values), windowStart: windowStart, now: now,
+ offsetSec: offsetSec, habitualMidsleepSec: habitualMidsleepSec)
+ } catch {
+ // Fail closed: an unread namespace is unknown, not empty. Returning no replacement source IDs
+ // prevents the persistence transaction from deleting valid markers after a transient read error.
+ trace?("stepsCycle status=error error=boundaryRecoveryRead")
+ return Result(stepsByWakeDay: [:], strainByWakeDay: [:], caloriesByWakeDay: [:],
+ workoutCountByWakeDay: [:], onsetByWakeDay: [:], firstWakeDay: nil,
+ markerUpdate: .preserve)
+ }
+ for item in recovered {
+ boundaries.append(item.boundary); wakeDayById[item.boundary.sleepId] = item.wakeDay
+ ownerById[item.boundary.sleepId] = item.owner; sleepContexts.append(item.sleepContext)
+ }
+ if let latest = boundaries.max(by: { $0.onset < $1.onset }),
+ let day = wakeDayById[latest.sleepId], let owner = ownerById[latest.sleepId] {
+ let active = DayCycleResolver.activeWindow(mode: mode,
+ latestSleep: DayCycleWindow(id: latest.sleepId, startInclusive: latest.onset,
+ endExclusive: now, displayDay: day, source: .detectedSleep), now: now,
+ offsetSec: offsetSec)
+ if active.source == .syntheticMidnight {
+ boundaries.append(.init(sleepId: active.id, onset: active.startInclusive))
+ wakeDayById[active.id] = active.displayDay; ownerById[active.id] = owner
+ }
+ }
+
+ let windows = PhysiologicalSteps.cycleWindows(boundaries, now: now)
+ cache.cycles = cache.cycles.filter { entry in windows.contains(where: { $0.sleepId == entry.key }) }
+ let priorities = Dictionary(candidates.map { ($0.owner, $0.priority) }, uniquingKeysWith: min)
+ let witnesses = Dictionary(uniqueKeysWithValues: nights.map { night in
+ let sleeps = night.sleeps.sorted { $0.startTs < $1.startTs }.map {
+ "\($0.startTs)-\($0.endTs):\($0.stagesJSON ?? "")"
+ }.joined(separator: "|")
+ return (night.daily.day, "\(night.owner):\(night.daily.steps.map(String.init) ?? "nil")|\(sleeps)")
+ })
+ var steps: [String: Int] = [:], onsets: [String: Int] = [:]
+ var strains: [String: Double] = [:], calories: [String: Double] = [:]
+ var workoutCounts: [String: Int] = [:]
+ windowLoop: for window in windows {
+ guard let day = wakeDayById[window.sleepId], let fallback = ownerById[window.sleepId] else { continue }
+ do {
+ onsets[day] = window.onset
+ // Store ranges are inclusive. Read the active-first WHOOP + canonical union without borrowing
+ // step coverage: an HR-only device may legitimately have no step rows.
+ let hrEndInclusive = window.endExclusive - 1
+ let owners = ([fallback] + physiologyOwners).reduce(into: [String]()) {
+ if !$0.contains($1) { $0.append($1) }
+ }
+ var hrByTimestamp: [Int: HRSample] = [:]
+ if hrEndInclusive >= window.onset {
+ for owner in owners {
+ let rows = (try? await store.hrSamples(
+ deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? []
+ for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row }
+ }
+ }
+ let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts }
+ let restingHR = nights.first(where: { $0.daily.day == day })?.daily.restingHr.map(Double.init)
+ ?? StrainScorer.defaultRestingHR
+ let effectiveMaxHR = maxHROverride ?? (profile.age > 0 ? StrainScorer.tanakaHRmax(age: profile.age) : nil)
+ if let strain = StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR,
+ restingHR: restingHR, method: effortMethod,
+ sex: profile.sex) { strains[day] = strain }
+ if !cycleHR.isEmpty {
+ calories[day] = Calories.estimateDayCalories(
+ cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)
+ }
+ let persistedWorkoutKeys = workouts
+ .filter { $0.startTs >= window.onset && $0.startTs < window.endExclusive }
+ .map { "\($0.startTs):\($0.endTs)" }
+ let freshDetectedKeys = nights.flatMap(\.workouts)
+ .filter { $0.start >= window.onset && $0.start < window.endExclusive }
+ .filter { detected in
+ !workouts.contains { persisted in
+ detected.start < persisted.endTs && persisted.startTs < detected.end
+ }
+ }
+ .map { "\($0.start):\($0.end)" }
+ workoutCounts[day] = Set(persistedWorkoutKeys + freshDetectedKeys).count
+ var ranked = priorities; ranked[fallback] = ranked[fallback] ?? ranked.values.min() ?? 0
+ var coverage: [PhysiologicalSteps.OwnerCoverage] = []
+ for (owner, priority) in ranked {
+ let span = try await store.stepTimestampCoverage(
+ deviceId: owner, from: window.onset, to: window.endExclusive)
+ if let first = span.first, let last = span.last {
+ coverage.append(.init(owner: owner, onset: first,
+ endExclusive: min(last + 1, window.endExclusive), priority: priority))
+ }
+ }
+ let segments = PhysiologicalSteps.ownerSegmentsFromCoverage(
+ window, coverage: coverage, fallbackOwner: fallback)
+ guard !segments.isEmpty else { continue }
+ let active = window.endExclusive == now
+ let identity = segments.enumerated().map { index, segment in
+ "\(segment.owner):\(segment.onset)-\(active && index == segments.count - 1 ? 0 : segment.endExclusive)"
+ }.joined(separator: ",")
+ var revisions: [String] = []
+ for segment in segments {
+ let revision = await store.stepDataRevisionSignature(
+ deviceId: segment.owner, from: segment.onset, to: segment.endExclusive)
+ revisions.append("\(segment.owner)=\(revision)")
+ }
+ let contextSignature = sleepContexts.filter { $0.end > window.onset && $0.start < window.endExclusive }
+ .sorted { $0.start < $1.start }.map { sleep in
+ "\(sleep.start)-\(sleep.end):" + sleep.stages.sorted { $0.start < $1.start }
+ .map { "\($0.start)-\($0.end)=\($0.stage)" }.joined(separator: ":")
+ }.joined(separator: "|")
+ let firstDay = AnalyticsEngine.dayString(window.onset, offsetSec: offsetSec)
+ let lastDay = AnalyticsEngine.dayString(max(window.onset, window.endExclusive - 1), offsetSec: offsetSec)
+ let dayWitness = witnesses.keys.filter { $0 >= firstDay && $0 <= lastDay }.sorted()
+ .map { "\($0)=\(witnesses[$0] ?? "")" }.joined(separator: "|")
+ let key = "\(identity)|\(window.sleepId)|\(window.onset)|\(active ? 0 : window.endExclusive)"
+ + "|stepRevision=\(revisions.joined(separator: "|"))|sleepContext=\(contextSignature)"
+ + "|days=\(dayWitness)"
+ var cached = cache.cycles[window.sleepId]
+ if cached?.key != key {
+ var count = SleepAwareStepCounter.Count.empty, pages = 0, samples = 0, evaluated = false
+ for (index, segment) in segments.enumerated() {
+ let hasClasses = try await store.hasStepActivityClasses(
+ deviceId: segment.owner, from: segment.onset, to: segment.endExclusive)
+ let accumulator = SleepAwareStepCounter.Accumulator(
+ sleepSessions: sleepContexts, hasActivityClasses: hasClasses)
+ var segmentSamples = 0
+ if index == 0, let predecessor = try await store.stepSampleBefore(
+ deviceId: segment.owner, before: segment.onset) {
+ accumulator.acceptPage([predecessor]); samples += 1; segmentSamples += 1
+ }
+ var cursor = segment.onset - 1
+ while cursor < segment.endExclusive {
+ let page = try await store.stepSamplesPage(deviceId: segment.owner,
+ afterExclusive: cursor, endExclusive: segment.endExclusive, limit: pageSize)
+ guard !page.isEmpty else { break }
+ accumulator.acceptPage(page); pages += 1; samples += page.count; segmentSamples += page.count
+ guard let last = page.last, last.ts >= cursor else { break }
+ cursor = last.ts
+ if page.count < pageSize { break }
+ }
+ let motion = try? await store.stepDiagnosticMotionCounts(
+ deviceId: segment.owner, from: segment.onset, to: segment.endExclusive)
+ accumulator.observeMotion(gravityCount: motion?.gravity ?? 0, auxCount: motion?.aux ?? 0)
+ if segmentSamples >= 2 { evaluated = true }
+ count = count.adding(accumulator.finish())
+ }
+ cached = CachedCycle(key: key, count: count, pages: pages, samples: samples, evaluated: evaluated)
+ cache.cycles[window.sleepId] = cached
+ }
+ guard let result = cached, result.evaluated else { continue }
+ let scaled = Int((Double(result.count.totalTicks) / max(ticksPerStep, 0.5)).rounded())
+ steps[day] = scaled
+ let status = active ? "active" : "closed"
+ trace?("stepsCycle wakeDay=\(day) status=\(status) onsetTs=\(window.onset) "
+ + "endTs=\(window.endExclusive) owner=\(identity) pages=\(result.pages) samples=\(result.samples) "
+ + "totalTicks=\(result.count.totalTicks) outside=\(result.count.acceptedOutsideSleepTicks) "
+ + "awakeGap=\(result.count.acceptedAwakeGapTicks) sleepBout=\(result.count.acceptedSleepBoutTicks) "
+ + "rejectedIsolatedSleep=\(result.count.rejectedIsolatedSleepTicks) "
+ + "rejectedClass=\(result.count.rejectedActivityClassTicks) "
+ + "rejectedImplausible=\(result.count.rejectedImplausibleTicks) "
+ + "gravitySamples=\(result.count.gravitySamplesAvailable) auxSamples=\(result.count.auxSamplesAvailable) "
+ + "ticksPerStep=\(ticksPerStep) scaledSteps=\(scaled)")
+ } catch {
+ trace?("stepsCycle wakeDay=\(day) status=error error=databaseRead")
+ continue windowLoop
+ }
+ }
+ let recoveredMarkers = recovered.map { SourcedMarker(deviceId: computedId($0.owner),
+ point: MetricPoint(day: $0.wakeDay, key: onsetKey, value: Double($0.boundary.onset))) }
+ return Result(stepsByWakeDay: steps, strainByWakeDay: strains, caloriesByWakeDay: calories,
+ workoutCountByWakeDay: workoutCounts, onsetByWakeDay: onsets,
+ firstWakeDay: wakeDayById.values.min(), markerUpdate: .replace(
+ points: recoveredMarkers,
+ sourceIds: Array(Set(candidates.map { computedId($0.owner) })).sorted()))
+ }
+
+ static func applying(_ result: Result, to daily: DailyMetric) -> DailyMetric {
+ let established = result.firstWakeDay.map { daily.day >= $0 } ?? false
+ let steps = established ? result.stepsByWakeDay[daily.day] : daily.steps
+ let strain = established ? result.strainByWakeDay[daily.day] : daily.strain
+ let calories = established ? result.caloriesByWakeDay[daily.day] : daily.activeKcalEst
+ let workouts = established ? result.workoutCountByWakeDay[daily.day] : daily.exerciseCount
+ return DailyMetric(day: daily.day, totalSleepMin: daily.totalSleepMin, efficiency: daily.efficiency,
+ deepMin: daily.deepMin, remMin: daily.remMin, lightMin: daily.lightMin,
+ disturbances: daily.disturbances, restingHr: daily.restingHr, avgHrv: daily.avgHrv,
+ recovery: daily.recovery, strain: strain, exerciseCount: workouts,
+ spo2Pct: daily.spo2Pct, skinTempDevC: daily.skinTempDevC, respRateBpm: daily.respRateBpm,
+ steps: steps, activeKcalEst: calories, spo2Red: daily.spo2Red,
+ spo2Ir: daily.spo2Ir, avgSdnn: daily.avgSdnn,
+ skinTempC: daily.skinTempC, sleepHrOnly: daily.sleepHrOnly)
+ }
+}
diff --git a/Strand/Data/DeviceRegistry.swift b/Strand/Data/DeviceRegistry.swift
index 5060fae5d7..032d70a1c3 100644
--- a/Strand/Data/DeviceRegistry.swift
+++ b/Strand/Data/DeviceRegistry.swift
@@ -92,6 +92,7 @@ final class DeviceRegistry: ObservableObject {
/// Best-effort: a store failure leaves the recordings and published state untouched. Awaits the delete
/// BEFORE `reload()` so the refreshed device list reflects the emptied recordings.
func deleteDeviceData(_ id: String, store: WhoopStore) async {
+ guard ImuSessionFileStore.shared.deleteDevice(id) else { return }
do {
try await store.deleteAllData(deviceId: id)
} catch {
@@ -108,6 +109,7 @@ final class DeviceRegistry: ObservableObject {
/// and refreshes the published list. Best-effort: if the data wipe fails the registry row is left in
/// place (we never leave orphaned recordings behind a removed row).
func forget(_ id: String, store: WhoopStore) async {
+ guard ImuSessionFileStore.shared.deleteDevice(id) else { return }
do {
try await store.deleteAllData(deviceId: id)
} catch {
diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift
index ecb8e01162..234f60d16c 100644
--- a/Strand/Data/IntelligenceEngine.swift
+++ b/Strand/Data/IntelligenceEngine.swift
@@ -21,6 +21,7 @@ final class IntelligenceEngine: ObservableObject {
/// is worth the few characters.
static let minHrSamples = 200
+ private let dayCycleCache = DayCycleIntelligenceIntegration.Cache()
private let repo: Repository
private let profile: ProfileStore
/// The CANONICAL id under whose `-noop` sibling this engine WRITES the computed daily rows, and from
@@ -137,6 +138,9 @@ final class IntelligenceEngine: ObservableObject {
private struct DayScan {
let result: AnalyticsEngine.DayResult
let rhrLine: String?
+ /// #1943 measure-only bin-population line (see `SleepStager.rhrBinGateLogLine`); nil on a clean
+ /// night, replayed with `rhrLine` so the two read together.
+ let rhrBinLine: String?
/// #1331 respiratory diagnostic line (see `respRateLogLine`); replayed with `rhrLine`.
let respLine: String?
/// CAPTURE-B (#814/#799): the resolved READ owner id this day was scored from, and how many HR rows
@@ -324,11 +328,60 @@ final class IntelligenceEngine: ObservableObject {
/// Counts + a window length only — same privacy class as the sibling `sleep day=` line, no PII. Pure so
/// it's unit-tested directly; byte-identical to the Android `sleepDetectNoNightLogLine`.
+/// Fold the per-day diagnostic lines onto the one channel that already replays them.
+ ///
+ /// `hrvDiag` is not really "the HRV line" any more — it is the loop-1 diagnostic channel, replayed
+ /// on the main actor by splitting on newlines. The Effort funnel rides it for the same reason the
+ /// NO-NIGHT line does: built where the inputs are in scope, emitted where the sink is safe to touch.
+ nonisolated static func mergedDayDiag(_ hrvDiag: String?, _ extra: [String]) -> String? {
+ var parts: [String] = []
+ if let hrvDiag, !hrvDiag.isEmpty { parts.append(hrvDiag) }
+ parts.append(contentsOf: extra)
+ return parts.isEmpty ? nil : parts.joined(separator: "\n")
+ }
+
nonisolated static func sleepDetectNoNightLogLine(day: String, hrCount: Int, rrCount: Int,
respCount: Int, gravCount: Int, stepCount: Int,
- providedCount: Int, windowHours: Int) -> String {
+ providedCount: Int, windowHours: Int,
+ skinCount: Int) -> String {
+ // `reason` names WHICH absence this is, because grav=0 is printed but its consequence is not.
+ // With no motion the stager has no HR-only fallback, so no quantity of HR can stage a night — a
+ // strap capability limit, not a coverage gap, and the two want completely different follow-ups.
+ // With motion present the inputs were there and staging still produced nothing, which is the case
+ // actually worth investigating.
+ let reason = gravCount == 0 ? "no-motion" : "staged-none"
+ // #1118 follow-up: name any stream that came back AT its read cap. A read that returns exactly the
+ // limit is the definition of truncated everywhere else here (`full.count >= limit`), and it is the
+ // one thing a reader cannot infer from the counts alone — `grav=192698` looks healthy until you
+ // know the cap it is 96% of.
+ //
+ // GRAVITY is why this exists. HR and R-R ride a SlidingStreamWindow, which counts its own
+ // truncations; gravity is a plain read with no counter at all, so a night clipped of its newest
+ // motion staged badly and said nothing. That is the difference between "this release fixes your
+ // night" and "your offload never ran", and a log could not tell them apart.
+ var atCap: [String] = []
+ // ONLY the plain reads. Gravity and skin are read whole, so a result of exactly the cap IS the
+ // truncation — the same `count >= limit` test used everywhere else here, and neither stream has a
+ // counter of its own, which is why this marker exists.
+ //
+ // HR and R-R are deliberately absent even though their counts are printed. They arrive through
+ // `SlidingStreamWindow.rows`, which returns `full.filter { ts in from...to }` — a SLICE of a read
+ // that usually spans more than this night. A spliced window that WAS truncated still hands back a
+ // slice under the cap, so the marker would silently fail to fire: a false negative on a line whose
+ // only value is that its absence means "not clipped". Their exact truncation count is already
+ // printed unconditionally once per pass by `WindowedStreamPlan.logLine` (`hrTruncated=`), so
+ // nothing is lost by declining to guess it per night.
+ //
+ // resp and steps are plain reads too, and equally judgeable, but stay unmarked on purpose: neither
+ // is a staging input, so clipping one cannot be the reason a night is missing. Their counts are
+ // printed as context. The marker answers "did a stream that could explain this get cut short",
+ // not "was every read complete".
+ if gravCount >= StreamReadCap.gravity { atCap.append("grav") }
+ if skinCount >= StreamReadCap.skin { atCap.append("skin") }
+ let capNote = atCap.isEmpty ? "" : " atCap=" + atCap.joined(separator: ",")
return "sleep-detect day=\(day) NO-NIGHT hr=\(hrCount) rr=\(rrCount) resp=\(respCount) "
- + "grav=\(gravCount) steps=\(stepCount) provided=\(providedCount) window=\(windowHours)h"
+ + "grav=\(gravCount) skin=\(skinCount) steps=\(stepCount) provided=\(providedCount) "
+ + "window=\(windowHours)h reason=\(reason)" + capNote
}
/// #674/#1244: the "sleep total with no matched session" divergence line. A COMPUTED day whose fresh
@@ -539,23 +592,22 @@ final class IntelligenceEngine: ObservableObject {
let respCfg = Baselines.metricCfg["resp"],
let skinCfg = Baselines.metricCfg["skin_temp"] else { return }
- // #836 (idle-tick gate): re-scoring a 21-day window re-reads ~21×54 h of raw HR and re-runs
+ // #836 (idle-tick gate): re-scoring a 21-day window re-reads ~21×54 h of raw data and re-runs
// analyzeDay over it. After a big Apple Health import (a reporter's: 2.1 M rows, ~190 k HR/day) that
// is multi-second, memory-heavy work, and the 15-minute steady-state tick (AppModel) repeats it
// every tick even when NOTHING new landed — the ongoing lag/crash in #836. A cheap whole-history HR
- // fingerprint (count+maxTs, indexed, no rows materialized) lets a NON-forced caller short-circuit
- // when the raw stream is byte-for-byte unchanged since the last successful run. All-or-nothing: it
+ // fingerprint (HR count+maxTs plus indexed row frontiers, no rows materialized) lets a NON-forced
+ // caller short-circuit when the raw streams are unchanged since the last successful run. All-or-nothing: it
// never produces a PARTIAL pass, so the window-wide reconciliation (stale-day eviction, detected-
// workout delete) is untouched and no computed history is dropped. Every real update path (sync
// backfill, import, sleep/workout edit, baseline recalibrate, timestamp heal) calls with the default
// `force: true` and always rescores, so a skipped tick can never hide new data.
- // #1392: fingerprint the raw HR stream ACROSS ALL DEVICES, not the engine's construction-time
+ // #1392: fingerprint raw scoring streams ACROSS ALL DEVICES, not the engine's construction-time
// `deviceId` (a `let` pinned to "my-whoop", never re-pointed when the active strap changes). On an
// Oura / Apple Watch / re-added-WHOOP install the per-device read returned 0, so this gate never
// fired and a night finishing after launch stayed unscored until relaunch. Scoring below still reads
// the registry's ACTIVE device (`owner`); only this change-detector needed to be cross-device.
- let wmKey: String = (try? await store.hrFingerprint())
- .map { "\($0.count):\($0.maxTs)" } ?? ""
+ let wmKey = (try? await store.analysisFingerprint()) ?? ""
// #1538: read the stored watermark ONCE for both gates and the attribution line below. There is no
// suspension point between them, so the three reads this replaces could not disagree — but the log
// line asserting `newData` and the gate deciding whether to run must be the SAME comparison by
@@ -565,7 +617,7 @@ final class IntelligenceEngine: ObservableObject {
return
}
// #1196/#1146: a FORCED post-offload pass can opt into the same fingerprint gate. An empty/duplicate
- // offload (fingerprint already == the watermark the last successful run advanced) has no new HR to
+ // offload (fingerprint already == the watermark the last successful run advanced) has no new raw data to
// score, so a re-score would reproduce IDENTICAL rows; skip the whole pass rather than churn the
// window. Over a flapping-link offload storm that churn made the reactive Trends/streak reads
// flicker between full and empty — a scare that looked like data loss (#1196). Scoped via
@@ -607,7 +659,18 @@ final class IntelligenceEngine: ObservableObject {
// between here and there, so "started and never finished" means exactly "killed", never a silent
// internal skip. `RescoreBackgroundPolicy` reads it to stop re-attempting a pass that cannot
// finish in the background, which is the livelock in #1538.
- RescoreBackgroundScheduler.markRescoreOwed()
+ // #1681: keep the token this debt was stamped with. At the end of the pass it is what tells our
+ // own debt apart from one a LATER trigger recorded while we were running - the latter must
+ // survive us, because the data it was recorded for arrived after we had already read our inputs.
+ //
+ // This is the SAME discipline the watermark beside it already used and the owed flag did not:
+ // capture the value at the start, compare against it at the end, never re-read. `wmKey` is read
+ // once at the top and the pass writes back THAT value, so HR arriving mid-pass leaves the
+ // watermark behind and the next trigger re-scores. The owed flag was the one piece of per-pass
+ // state that skipped the capture and just cleared, which is exactly where #1681 lived. The
+ // Kotlin post-offload gate makes the same point in its own words: "captured before the run,
+ // written only on success".
+ let owedToken = RescoreBackgroundScheduler.markRescoreOwed()
// #899-A re-arm: clear the lock, then if a forced rescore was dropped while this pass held it,
// run it ONCE. The flag is cleared BEFORE the re-invoke (a single re-arm), so a forced call landing
// DURING the re-invoke re-arms it again but a quiet one does not , this can never recurse unbounded.
@@ -717,7 +780,7 @@ final class IntelligenceEngine: ObservableObject {
// and the Sleep tab resolve to the identical block. (#547)
let (habitualMidsleepSec, nightlyHours) = await Self.computeHabitualSleep(
store: store, importedId: deviceId, computedId: deviceId + "-noop",
- windowStart: nowLocalMidnight - maxDays * 86_400 - 30 * 3_600,
+ windowStart: nowLocalMidnight - maxDays * 86_400 - StreamReadCap.lookbackSeconds,
windowEnd: now, offsetSec: tzOffset)
// Wave 0 (SL1/T1): personal sleep REGULARITY + population-anchored NEED, computed ONCE from the
// trailing per-night durations and threaded to every analyzeDay below (mirrors the midsleep
@@ -773,6 +836,7 @@ final class IntelligenceEngine: ObservableObject {
// #1545: the Effort TRIMP recipe, read ONCE per pass. Global (same for every day), so it folds
// into the config signature below rather than the per-day key.
let effortMethodGlobal = PuffinExperiment.effortMethod
+ let dayCycleMode = DayCycleMode.persisted(UserDefaults.standard.string(forKey: DayCycleMode.storageKey))
// ── #1005 BATTERY: per-day reuse cache setup (see `dayScanCache`) ────────────────────────────
// The stager toggles are read per-day inside the loop below, but they are global (same value every
@@ -825,6 +889,7 @@ final class IntelligenceEngine: ObservableObject {
// produced under one method is stale the moment the user switches — serving it would show a
// window of days scored by a recipe the user just turned off, with nothing to explain it.
"\(effortMethodGlobal)",
+ dayCycleMode.rawValue,
].joined(separator: "|")
// Drop the whole cache on a config change, then snapshot it into a Sendable `let` for the detached
// loop (the engine is @MainActor; the loop can't touch `self`). The loop returns the updated cache
@@ -851,7 +916,7 @@ final class IntelligenceEngine: ObservableObject {
var skippedSleepDays: [(day: String, hrSamples: Int)] = []
// #938: the WHOOP 4.0 ADC offset is per-device, not per-night. Learn one anchor per owner
// from the whole scan window and reuse it for every night so cross-night deviations survive.
- let skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - 30 * 3_600
+ let skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - StreamReadCap.lookbackSeconds
let skinAnchorScanTo = nowLocalMidnight + 18 * 3_600
var skinAnchorByOwner: [String: Double] = [:]
var skinAnchorResolvedOwners = Set()
@@ -869,11 +934,33 @@ final class IntelligenceEngine: ObservableObject {
// Together with `dayCacheReused` this is the honest denominator for the reuse ratio — see the
// diagnostic at the end of the loop.
var dayCacheCacheable = 0
+ // #1538: backward sliding read buffers for the two heavy streams. Pass 1 walks backwards over
+ // 54-hour windows on a 24-hour stride, so consecutive days overlap by 30 hours and every row
+ // was being materialised ~2.25x per pass. These read the missing stride only; every case the
+ // planner cannot prove safe falls back to exactly the read that shipped before them. HR and
+ // R-R only: the other eight streams are thousands of rows against these two's tens of
+ // thousands, so this is nearly all of the win for two call sites of blast radius.
+ // Each window's read cap and its truncation threshold must be the SAME number: the window
+ // declines to slice a read that came back at the cap, because `ORDER BY ts ASC LIMIT` drops the
+ // NEWEST rows. Were the two to drift apart, a truncated read would stop being recognised and the
+ // buffer would be sliced while missing its tail — wrong scoring inputs, silently. Passing one
+ // `StreamReadCap` value into both slots below keeps that guarantee per stream.
+ //
+ // #1538: the two streams no longer share ONE cap. 200,000 was sized for HR (a full 54h window is
+ // ~194,400 rows, 3% under) and silently truncated R-R, which is per-BEAT and runs to 233k-389k
+ // over the same window. A field capture showed `rr[... truncated=10]` — ten nights scored on R-R
+ // missing its tail. See `StreamReadCap` for the arithmetic.
+ let hrWindow = SlidingStreamWindow(tsOf: { $0.ts }, limit: StreamReadCap.hr) { o, f, t in
+ try? await store.hrSamples(deviceId: o, from: f, to: t, limit: StreamReadCap.hr)
+ }
+ let rrWindow = SlidingStreamWindow(tsOf: { $0.ts }, limit: StreamReadCap.rr) { o, f, t in
+ try? await store.rrIntervals(deviceId: o, from: f, to: t, limit: StreamReadCap.rr)
+ }
for offset in 0..= IntelligenceEngine.minHrSamples else {
// This day still paid for its read; count it, or the tally under-reports exactly the
// sparse-history installs where reads dominate most.
@@ -952,7 +1050,7 @@ final class IntelligenceEngine: ObservableObject {
skippedSleepDays.append((day: day, hrSamples: hr.count))
continue
}
- let rr = (try? await store.rrIntervals(deviceId: owner, from: from, to: to, limit: 200_000)) ?? []
+ let rr = await rrWindow.rows(owner: owner, from: from, to: to)
// `forScoring` drops an Oura ring's respiration rows: those are the ring's OWN per-window
// RATE (0x6A, milli-bpm, ~1 row per 5 min), stored as instrumentation, while the stager
// reads this stream as a ~1 Hz raw ADC waveform. Refusing by provenance keeps the
@@ -970,9 +1068,10 @@ final class IntelligenceEngine: ObservableObject {
limit: 200_000)) ?? []
let resp = OuraRespScale.forScoring(respRows, deviceId: owner)
let vendorResp = OuraRespScale.forVendorRate(respRows, deviceId: owner)
- let grav = (try? await store.gravitySamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? []
+ let grav = (try? await store.gravitySamples(deviceId: owner, from: from, to: to,
+ limit: StreamReadCap.gravity)) ?? []
let steps = (try? await store.stepSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? []
- let skin = (try? await store.skinTempSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? []
+ let skin = (try? await store.skinTempSamples(deviceId: owner, from: from, to: to, limit: StreamReadCap.skin)) ?? []
// #93: WHOOP 4.0 raw SpO2 PPG samples for the night; analyzeDay banks the nightly red/IR ADC
// means on the DailyMetric. Empty on a 5/MG (no v24 spo2 channels) → the raw means stay nil.
let spo2 = (try? await store.spo2Samples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? []
@@ -1000,7 +1099,7 @@ final class IntelligenceEngine: ObservableObject {
let windowSkin = (try? await store.skinTempSamples(deviceId: owner,
from: skinAnchorScanFrom,
to: skinAnchorScanTo,
- limit: 200_000)) ?? []
+ limit: StreamReadCap.skin)) ?? []
if let anchor = Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { $0.raw }) {
skinAnchorByOwner[owner] = anchor
}
@@ -1117,17 +1216,58 @@ final class IntelligenceEngine: ObservableObject {
// untouched; analyzeDay still lets a DETECTED session win where the two overlap. Reconstruct the
// pure SleepSession from each stored CachedSleepSession (a minute-dict import row decodes to
// nothing and is skipped, so only real stage timelines are injected).
+ // #804 Fix A + #1801. Two different questions share the "this day has no motion" gate.
+ //
+ // #804 hands over a device's OWN persisted hypnogram (an Oura ring's SleepNet night) and is
+ // deliberately not applied to the import namespace. #1801 stages from heart rate when there
+ // is no hypnogram at all — and must NOT inherit #804's owner exclusion, which is the bug
+ // this replaces: `resolveDayOwner` returns the imported id whenever the owner source is
+ // absent or the candidates collapse to it, so on a live 5/MG install the whole branch was
+ // skipped before any heart rate was looked at.
+ //
+ // The stored lookup now runs for every no-motion day, so "nothing else knows about this
+ // night" is checked rather than assumed. A day that HAS stored sessions is left alone
+ // whoever owns it.
let providedSleep: [SleepSession]
- if owner != Repository.whoopSource, grav.count < 2 {
+ if grav.count < 2 {
let persisted = (try? await store.sleepSessions(deviceId: owner, from: from, to: to,
limit: 4000)) ?? []
- providedSleep = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) }
+ let stored = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) }
+ if owner != Repository.whoopSource, !stored.isEmpty {
+ traceSink?(SleepStager.GateTrace.hrOnlyGateLine(
+ attempted: false, reason: "stored-hypnogram",
+ gravRows: grav.count, storedNights: stored.count))
+ providedSleep = stored
+ } else if !stored.isEmpty {
+ traceSink?(SleepStager.GateTrace.hrOnlyGateLine(
+ attempted: false, reason: "stored-sessions-exist",
+ gravRows: grav.count, storedNights: stored.count))
+ providedSleep = []
+ } else {
+ // Reachable for ANY owner now, which widens this past the 5/MG it was built for:
+ // a WHOOP 4.0 day that banked nothing at all also lands here, where the owner
+ // check previously blocked it. A normal 4.0 day is untouched — it streams
+ // gravity, so it never reaches this gate.
+ traceSink?(SleepStager.GateTrace.hrOnlyGateLine(
+ attempted: true, reason: "no-motion-no-hypnogram",
+ gravRows: grav.count, storedNights: 0))
+ providedSleep = SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: resp,
+ traceSink: traceSink)
+ }
} else {
providedSleep = []
}
+
let tScore0 = Date()
dayPrepSeconds += tScore0.timeIntervalSince(tPrep0)
- let res = AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, resp: resp,
+ // #1770 follow-up: the Effort ring's funnel. Collected here rather than sent straight
+ // to `diagnosticSink`, because that sink is main-actor isolated and this loop is not —
+ // the same reason `hrvDiag` is carried on the scan and replayed below. A local buffer
+ // crosses no actor.
+ var strainDiagLines: [String] = []
+ let res = AnalyticsEngine.analyzeDay(day: day,
+ strainDiag: { strainDiagLines.append($0) },
+ hr: hr, rr: rr, resp: resp,
vendorResp: vendorResp, gravity: grav,
steps: steps, dayHr: dayHr, daySteps: daySteps,
dayGravity: dayGrav,
@@ -1192,7 +1332,8 @@ final class IntelligenceEngine: ObservableObject {
hrvDiag = Self.sleepDetectNoNightLogLine(
day: day, hrCount: hr.count, rrCount: rr.count, respCount: resp.count,
gravCount: grav.count, stepCount: steps.count,
- providedCount: providedSleep.count, windowHours: windowHours)
+ providedCount: providedSleep.count, windowHours: windowHours,
+ skinCount: skin.count)
} else {
hrvDiag = nil
}
@@ -1224,9 +1365,12 @@ final class IntelligenceEngine: ObservableObject {
// covers 1.25x its wall-clock reads ~197 ms across a sleeping night, against a 40-100 ms
// physiological range. Printing that number beside the verdict that says it cannot be
// trusted invites it to be read as a measurement, so it is withheld instead; the
- // `rrIntegrity=` field on the same line says why. RMSSD/meanNN are NOT withheld — mean
- // rate survives an over-count, and RMSSD's dominant error was the emission order fixed
- // at the write path (#1072).
+ // `rrIntegrity=` field on the same line says why. meanNN is NOT withheld: mean rate
+ // survives an over-count. RMSSD is not withheld FROM THIS LINE either, but it no longer
+ // reaches the card, the daily row or the baseline on an over-counted night — the gate
+ // that stops it lives in `SleepStager.sessionAvgHRV` (#1118). Printing the computed
+ // value here while the app refused to use it is a known wart, held back to keep this
+ // line byte-identical to the Kotlin twin, whose method budget has no room for it.
// P7' follow-up: the over-count verdict is necessary but NOT sufficient. The
// 2026-08-06 Oura night measured coverage 1.03 / `plausible` — no duplication at
// all, its records tiling the timeline at a fill ratio of 0.990 — and still printed
@@ -1352,11 +1496,18 @@ final class IntelligenceEngine: ObservableObject {
// Computed here (pure inputs) and carried out so the main actor can replay it through
// `diagnosticSink` in the SAME per-day order , the sink is a MainActor-bound closure.
var rhrLine: String?
+ var rhrBinLine: String?
if let floor = res.daily.restingHr {
let inBedBpms = hr.filter { s in
res.cachedSleep.contains { s.ts >= $0.startTs && s.ts < $0.endTs }
}.map { $0.bpm }
rhrLine = Self.rhrFloorMeanLogLine(day: res.daily.day, floor: floor, inBedBpms: inBedBpms)
+ // #1943: conformance check that the gate `sessionRestingHR` now applies agrees with
+ // the shipped floor. Silent when the gate is correctly applied.
+ rhrBinLine = SleepStager.rhrBinGateLogLine(
+ day: res.daily.day,
+ sessions: res.cachedSleep.map { ($0.startTs, $0.endTs) },
+ hr: hr, shippedFloor: floor)
}
// #1331 respiratory diagnostic — a run of nil nights localises when it stopped. Same
// pure-compute-here / replay-on-main-actor path as rhrLine.
@@ -1406,10 +1557,12 @@ final class IntelligenceEngine: ObservableObject {
// windowing + delegation lives in the byte-identical, tested `AnalyticsEngine`.
let (primarySessionRHR, primarySessionRHRCoverage) =
AnalyticsEngine.primarySessionRestingHRWithCoverage(sessions: res.sleepSessions, hr: hr)
- let scan = DayScan(result: res, rhrLine: rhrLine, respLine: respLine,
+ let scan = DayScan(result: res, rhrLine: rhrLine, rhrBinLine: rhrBinLine,
+ respLine: respLine,
readOwner: owner, hrRows: hr.count,
sleepTrace: sleepTrace, stepsTrace: stepsTrace, hrvTrace: hrvTrace,
- hrvDiag: hrvDiag, spo2Candidate: spo2CandidateMean,
+ hrvDiag: Self.mergedDayDiag(hrvDiag, strainDiagLines),
+ spo2Candidate: spo2CandidateMean,
hrvOverCounted: hrvOverCounted,
primarySessionRHR: primarySessionRHR,
primarySessionRHRCoverage: primarySessionRHRCoverage)
@@ -1447,6 +1600,15 @@ final class IntelligenceEngine: ObservableObject {
// narrowing; `analyzeDay` dominating means it is not, whatever the row counts look like.
skippedDayLines.append("analyzeRecent cost prep=\(Int(dayPrepSeconds * 1000))ms "
+ "score=\(Int(dayScoreSeconds * 1000))ms")
+ // #1538: what the sliding windows saved, beside the line the decision to build them was made
+ // from. A pass where `served` is ~0 means they are declining (truncation, an owner flip, or a
+ // gap left by a dayCache hit) and the reads are back to what they were — the honest outcome
+ // rather than a silent one. Byte-identical to the Kotlin `analyzeWindowsLogLine`.
+ skippedDayLines.append(WindowedStreamPlan.logLine(
+ hrRead: hrWindow.rowsRead, hrServed: hrWindow.rowsServed,
+ hrTruncated: hrWindow.truncatedReads,
+ rrRead: rrWindow.rowsRead, rrServed: rrWindow.rowsServed,
+ rrTruncated: rrWindow.truncatedReads))
return (out, skippedDayLines, dayScanCacheLocal)
}.value
// #1005: write the loop's updated reuse cache back to the (main-actor) stored property. The pass ran
@@ -1502,6 +1664,7 @@ final class IntelligenceEngine: ObservableObject {
primarySessionRHRCoverageByDay[res.daily.day] = cov
}
if let line = scan.rhrLine { diagnosticSink?(line, nil) }
+ if let line = scan.rhrBinLine { diagnosticSink?(line, nil) }
if let line = scan.respLine { diagnosticSink?(line, nil) }
// Sleep & Rest test mode (E5): replay this day's gate-trace + Rest lines tagged `.sleep` so they
// land under the profile tag in the export. Empty unless the mode is active.
@@ -1627,7 +1790,7 @@ final class IntelligenceEngine: ObservableObject {
// re-labelled rows (both written under `deviceId`), and apple-health carries Health imports ,
// a detected bout overlapping ANY of them is skipped below. Port of the Android dedup block.
// (`computedId` is bound once above, before the off-actor scan loop.)
- let windowStart = now - maxDays * 86_400 - 30 * 3_600
+ let windowStart = now - maxDays * 86_400 - StreamReadCap.lookbackSeconds
var realWorkouts = (try? await store.workouts(deviceId: deviceId, from: windowStart,
to: now, limit: 100_000)) ?? []
realWorkouts += (try? await store.workouts(deviceId: "apple-health", from: windowStart,
@@ -1660,6 +1823,49 @@ final class IntelligenceEngine: ObservableObject {
// already staged from raw (idempotent) and for imported nights (raw never dense). This MUST run
// before the scoring loop so the healed stages flow into Rest/recovery this same pass.
let editedRows = await repo.selfHealEditedStages(from: windowStart, to: now)
+ var cycleCandidates: [(owner: String, priority: Int)] = regDevices
+ .map { device in
+ let priority: Int
+ if device.id == regActiveId { priority = 0 }
+ else if device.status == .archived { priority = 4 }
+ else if device.sourceKind == .activityFile { priority = 3 }
+ else if device.isImportSource { priority = 2 }
+ else { priority = 1 }
+ return (device.id, priority)
+ }
+ if !cycleCandidates.contains(where: { $0.owner == regActiveId }) {
+ cycleCandidates.append((regActiveId, 0))
+ }
+ let physiologyOwners = ([regActiveId] + regDevices.filter {
+ $0.brand.caseInsensitiveCompare("WHOOP") == .orderedSame
+ }.map(\.id) + [Repository.whoopSource]).reduce(into: [String]()) {
+ if !$0.contains($1) { $0.append($1) }
+ }
+ let cycleWorkouts = await repo.workoutRows(days: maxDays + 2)
+ let physiologicalSteps = await DayCycleIntelligenceIntegration.compute(
+ nights: scoredNights.map { night in
+ DayCycleIntelligenceIntegration.Night(
+ daily: night.daily,
+ sleeps: night.cachedSleep,
+ workouts: night.workouts,
+ owner: resolvedScoreOwnerByDay[night.daily.day] ?? regActiveId)
+ },
+ editedRows: editedRows,
+ store: store,
+ candidates: cycleCandidates,
+ physiologyOwners: physiologyOwners,
+ workouts: cycleWorkouts,
+ windowStart: windowStart,
+ now: now,
+ offsetSec: tzOffset,
+ habitualMidsleepSec: habitualMidsleepSec,
+ ticksPerStep: up.stepTicksPerStep,
+ mode: dayCycleMode,
+ cache: dayCycleCache,
+ profile: up,
+ maxHROverride: maxHR,
+ effortMethod: effortMethodGlobal,
+ trace: stepsTraceActive ? { self.diagnosticSink?($0, .steps) } : nil)
// #299: `editsByStart` is now built PER DAY inside the scoring loop (scoped to the day each edit
// belongs to), NOT window-wide here. sleepEditedDaily folds any edited row that isn't a twin of THIS
// day's detected sessions in as a "manual" block, so a window-wide edit set let ONE user edit /
@@ -1704,8 +1910,9 @@ final class IntelligenceEngine: ObservableObject {
// every night. `effectiveStartTs` (the #318 user-corrected onset) is preserved on the row.
let dayEditedRows = Self.editedRowsForDay(editedRows, day: night.daily.day, tzOffsetSeconds: tzOffset)
let editsByStart = Dictionary(dayEditedRows.map { ($0.startTs, $0) }, uniquingKeysWith: { a, _ in a })
- let daily = sleepEditedDaily(night.daily, detected: night.cachedSleep, editsByStart: editsByStart,
+ var daily = sleepEditedDaily(night.daily, detected: night.cachedSleep, editsByStart: editsByStart,
habitualMidsleepSec: habitualMidsleepSec)
+ daily = DayCycleIntelligenceIntegration.applying(physiologicalSteps, to: daily)
let recovery = recomputeRecovery(daily, baselines2)
// Charge term-breakdown trace (Group G): only when the Recovery test mode is on. Emits which
// term moved Charge and which was nil and forced the renorm, tagged `.recovery`. The trace's
@@ -1725,7 +1932,7 @@ final class IntelligenceEngine: ObservableObject {
// off the HRV baseline state rather than a blanket `.solid`, so a thin/provisional baseline shows
// EST. not REL. Pure presentation upstream of the UI; the score itself is unchanged.
let chargeConf = ScoreConfidence.charge(recovery: recovery, hrvBaseline: baselines2.hrv)
- out.append(Computed(day: daily.day, recovery: recovery, strain: night.strain,
+ out.append(Computed(day: daily.day, recovery: recovery, strain: daily.strain,
sleepMin: daily.totalSleepMin, hrv: daily.avgHrv,
rhr: daily.restingHr, source: source, confidence: chargeConf,
drivers: drivers, skinTempRel: skinRel))
@@ -1799,6 +2006,11 @@ final class IntelligenceEngine: ObservableObject {
if let rest = AnalyticsEngine.Rest.composite(daily: daily) {
restPoints.append(MetricPoint(day: daily.day, key: "sleep_performance", value: rest))
}
+ if let onset = physiologicalSteps.onsetByWakeDay[daily.day] {
+ restPoints.append(MetricPoint(day: daily.day,
+ key: DayCycleIntelligenceIntegration.onsetKey,
+ value: Double(onset)))
+ }
// #103: persist the SpO₂ candidate @82 nightly mean to metricSeries as "spo2_candidate" so the
// Blood Oxygen tile can surface it as a "strap estimate (unverified)" fallback when the toggle
// is ON. Written under the "-noop" computed device ID, never to `spo2Pct` — the candidate has
@@ -2003,13 +2215,29 @@ final class IntelligenceEngine: ObservableObject {
provenanceByCell["\(point.day)\u{1F}\(point.key)"] =
ScoreInputProvenanceRow(day: point.day, key: point.key, sourceId: source)
}
+ let markerKeys: [String]
+ let markerPoints: [SourcedMetricPoint]
+ let markerSources: [String]
+ switch physiologicalSteps.markerUpdate {
+ case .preserve:
+ markerKeys = []; markerPoints = []; markerSources = []
+ case let .replace(points, sourceIds):
+ markerKeys = [DayCycleIntelligenceIntegration.onsetKey]
+ markerPoints = points.map {
+ SourcedMetricPoint(deviceId: $0.deviceId, point: $0.point)
+ }
+ markerSources = sourceIds
+ }
try? await store.persistComputedScores(
dailyMetrics: dailies,
metricPoints: restPoints,
provenance: Array(provenanceByCell.values),
deviceId: computedId,
from: oldestDay,
- to: newestDay
+ to: newestDay,
+ replaceMetricKeys: markerKeys,
+ additionalMetricPoints: markerPoints,
+ replaceMetricSourceIds: markerSources
)
// Now evict only the STALE computed rows in the window , those a prior (e.g. UTC-keyed) run left
@@ -2148,6 +2376,13 @@ final class IntelligenceEngine: ObservableObject {
}
return (refSteps, motion)
}.value
+ // #1816: persist whether the strap has banked ANY motion in the calibration scan window, so the
+ // Today tile can distinguish "Need N more phone-step days" (motion exists, phone half missing)
+ // from "No motion synced yet" (the motion half is the blocker, and no number of phone-step days
+ // will move the estimate or the fit). A step estimate is `motion * coefficient`, so with the
+ // motion half missing the caption that names only the phone half is a lie — it sent a field
+ // reporter to enter Apple Health steps by hand expecting calibration to start, which it cannot.
+ profile.stepsHasBankedMotion = !motionByDay.isEmpty
// Build calibration points only for days with BOTH a motion volume and a real phone step count.
let calPoints = motionByDay.compactMap { (day, motion) -> StepsEstimateEngine.CalibrationPoint? in
guard let s = refStepsByDay[day] else { return nil }
@@ -2356,8 +2591,8 @@ final class IntelligenceEngine: ObservableObject {
// Sleep tab stops showing the removed duplicates right away.
if !dailies.isEmpty || !healDropped.isEmpty { await repo.refresh() }
- // #836: record the raw-HR fingerprint this run scored against, so a later NON-forced tick can
- // short-circuit while it's unchanged. Written ONLY here at the end of a completed run (never on an
+ // #836/#sleep-sync: record the complete raw-analysis fingerprint this run scored against, so a later
+ // NON-forced tick can short-circuit while it's unchanged. Written ONLY at the end of a completed run (never on an
// early guard-return), so an interrupted/failed run can't advance the watermark past unscored data.
if !wmKey.isEmpty { UserDefaults.standard.set(wmKey, forKey: Self.analyzeWatermarkKey) }
// #1538: clear the started-mark and bank how long a COMPLETED pass costs on this install. The
@@ -2365,13 +2600,20 @@ final class IntelligenceEngine: ObservableObject {
// background wake from one that never could, instead of guessing from a constant — the cost varies
// by more than an order of magnitude with history size.
let elapsed = Date().timeIntervalSince(reScoreStart)
- RescoreBackgroundScheduler.markRescoreCompleted(seconds: elapsed)
+ let settled = RescoreBackgroundScheduler.markRescoreCompleted(seconds: elapsed, owedToken: owedToken)
diagnosticSink?("re-score: done — scored \(scoredNights.count) night(s) in \(Int(elapsed * 1000)) ms (#1005)", nil)
+ // #1681: a pass that completes while leaving the mark SET looks identical in a capture to one that
+ // cleared it. Rare-event evidence, so always-on: it costs a line only when it actually happens,
+ // and it is exactly what is missing when someone reports the app re-scoring on every launch.
+ if !settled {
+ diagnosticSink?("re-score: debt NOT settled — a newer re-score was recorded while this pass "
+ + "was running, so the mark stays and another pass will run (#1681)", nil)
+ }
}
- /// UserDefaults key for the #836 idle-tick gate: the `(count:maxTs)` HR fingerprint the last completed
+ /// UserDefaults key for the #836 idle-tick gate: the complete raw-analysis fingerprint the last completed
/// `analyzeRecent` scored against. A non-forced tick whose current fingerprint equals this skips the
- /// 21-day rescore; cleared implicitly by any HR insert/delete (the fingerprint moves), so it self-heals.
+ /// 21-day rescore; cleared implicitly by any scoring-stream insert (the fingerprint moves), so it self-heals.
private static let analyzeWatermarkKey = "noop.analyzeWatermark"
/// CAPTURE-B (#814/#799): build the universal `dayOwner …` self-diagnostic line VERBATIM (the Test
@@ -2807,7 +3049,8 @@ extension DailyMetric {
avgHrv: avgHrv, recovery: r, strain: strain, exerciseCount: exerciseCount,
spo2Pct: spo2Pct, skinTempDevC: sd, respRateBpm: respRateBpm,
steps: steps, activeKcalEst: activeKcalEst,
- spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, skinTempC: sa)
+ spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, skinTempC: sa,
+ sleepHrOnly: sleepHrOnly)
}
/// Rebuild with substituted sleep-derived fields (a user-corrected wake window), leaving every
@@ -2819,7 +3062,7 @@ extension DailyMetric {
strain: strain, exerciseCount: exerciseCount, spo2Pct: spo2Pct,
skinTempDevC: skinTempDevC, respRateBpm: respRateBpm, steps: steps,
activeKcalEst: activeKcalEst, spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn,
- skinTempC: skinTempC)
+ skinTempC: skinTempC, sleepHrOnly: sleepHrOnly)
}
}
diff --git a/Strand/Data/KeyMetricPrefs.swift b/Strand/Data/KeyMetricPrefs.swift
index 91b050395a..49d12d8cd6 100644
--- a/Strand/Data/KeyMetricPrefs.swift
+++ b/Strand/Data/KeyMetricPrefs.swift
@@ -26,6 +26,11 @@ enum KeyMetric: String, CaseIterable, Identifiable {
case steps
case weight
case calories
+ /// Added 2026-08-24 (queue 11c follow-up): Skin Temp was already a "Your Cards" (`DashboardCard`)
+ /// option, but was never offered as a Key Metrics tile — not a bug, just never added. New case, NOT
+ /// added to `defaultOrder` below, so an existing user's saved layout (and a fresh install's default)
+ /// is byte-identical to before; only opts in via the layout editor.
+ case skinTemp
var id: String { rawValue }
@@ -42,6 +47,9 @@ enum KeyMetric: String, CaseIterable, Identifiable {
case .steps: return String(localized: "Steps")
case .weight: return String(localized: "Weight")
case .calories: return String(localized: "Calories")
+ // Same short label the sibling "Your Cards" tile (`DashboardCard.skinTemp`) already uses —
+ // reuses its translation key rather than adding a new "Skin Temperature" one for this tile.
+ case .skinTemp: return String(localized: "Skin Temp")
}
}
diff --git a/Strand/Data/Profile.swift b/Strand/Data/Profile.swift
index 46ac4316dc..7e95727ccc 100644
--- a/Strand/Data/Profile.swift
+++ b/Strand/Data/Profile.swift
@@ -56,6 +56,16 @@ final class ProfileStore: ObservableObject {
@Published var stepsCalibrationManual: Bool { didSet { d.set(stepsCalibrationManual, forKey: K.stepsManualFlag) } }
/// User-set manual coefficient. 0 = auto-fit (nil to the engine); > 0 = manual override.
@Published var stepsManualCoefficient: Double { didSet { d.set(max(0, stepsManualCoefficient), forKey: K.stepsManualCoeff) } }
+ /// #1816: true when the strap has banked ANY motion (gravity samples → `dayMotionIntensity > 0`)
+ /// in the calibration scan window. Written by `IntelligenceEngine` on every analytics pass so it
+ /// tracks a fresh strap's first sync without a separate query. The Today tile reads this to decide
+ /// whether "Need N more days where your phone also counted steps" is the honest caption or a lie:
+ /// a step estimate is `motion * coefficient`, so with the motion half missing neither the estimate
+ /// nor the fit moves however many phone-counted days the user collects. The caption that names only
+ /// the phone half is actively misleading — it sent a field reporter to enter Apple Health steps by
+ /// hand expecting calibration to start, which it cannot without strap motion. Twin of Android's
+ /// `ProfileStore.stepsHasBankedMotion`.
+ @Published var stepsHasBankedMotion: Bool { didSet { d.set(stepsHasBankedMotion, forKey: K.stepsHasMotion) } }
// ── Profile picture (optional, on-device only) ──────────────────────────────────────────────
/// The user's chosen profile photo as JPEG bytes, or nil for the default SF-Symbol fallback.
@@ -85,6 +95,7 @@ final class ProfileStore: ObservableObject {
static let stepsConfidence = "profile.stepsCalibrationConfidence"
static let stepsManualFlag = "profile.stepsCalibrationManual"
static let stepsManualCoeff = "profile.stepsManualCoefficient"
+ static let stepsHasMotion = "profile.stepsHasBankedMotion"
static let avatar = "profile.avatarImageData"
}
@@ -124,6 +135,7 @@ final class ProfileStore: ObservableObject {
stepsCalibrationConfidence = d.object(forKey: K.stepsConfidence) as? Double ?? 0
stepsCalibrationManual = d.object(forKey: K.stepsManualFlag) as? Bool ?? false
stepsManualCoefficient = max(0, d.object(forKey: K.stepsManualCoeff) as? Double ?? 0)
+ stepsHasBankedMotion = d.object(forKey: K.stepsHasMotion) as? Bool ?? false
avatarImageData = d.data(forKey: K.avatar)
}
diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift
index 1e6d455d4e..96f0bf5c54 100644
--- a/Strand/Data/Repository.swift
+++ b/Strand/Data/Repository.swift
@@ -266,6 +266,34 @@ final class Repository: ObservableObject {
// MARK: - Union reads (active strap + canonical)
//
+ /// Raw physiological streams are a worn timeline, not a property of whichever strap is active now.
+ /// Resolve every registered physical WHOOP, active first, and keep the canonical import namespace as
+ /// the final fallback. Archived devices intentionally remain: archive means "stop connecting, keep
+ /// data", and historical timelines must not orphan their retained samples. The active id remains first
+ /// even for a non-WHOOP provider, preserving the pre-multi-strap cross-provider path.
+ private func rawPhysiologyReadIds(store: WhoopStore) -> [String] {
+ let paired = (try? DeviceRegistryStore(dbQueue: store.registryWriter).all()) ?? []
+ let registeredWhoops = paired.filter {
+ $0.brand.caseInsensitiveCompare("WHOOP") == .orderedSame
+ }.map(\.id)
+ return Self.rawWhoopSourceIds(activeDeviceId: deviceId, registeredWhoopIds: registeredWhoops)
+ }
+
+ /// Pure ordering contract shared with Android's parity guard: current active source first, every other
+ /// registered WHOOP in stable registry order, canonical history last; duplicates collapse.
+ nonisolated static func rawWhoopSourceIds(activeDeviceId: String,
+ registeredWhoopIds: [String]) -> [String] {
+ let canonical = Self.whoopSource
+ let middle = registeredWhoopIds.filter { $0 != activeDeviceId && $0 != canonical }
+ return ([activeDeviceId] + middle + [canonical]).reduce(into: [String]()) {
+ if !$0.contains($1) { $0.append($1) }
+ }
+ }
+
+ private func rawComputedReadIds(store: WhoopStore) -> [String] {
+ rawPhysiologyReadIds(store: store).map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" }
+ }
+
// Each helper reads the SAME store query across `importedReadIds` (active strap + canonical "my-whoop")
// and concatenates the rows, ACTIVE STRAP FIRST. The callers feed the concatenated rows into the SAME
// per-day dedup they already used (mergeDaily/mergeSleep key by day; the daily/series facades dedup
@@ -285,6 +313,61 @@ final class Repository: ObservableObject {
return byDay.values.sorted { $0.day < $1.day }
}
+ /// Gravity samples across the imported union, deduped by timestamp with the ACTIVE STRAP winning.
+ ///
+ /// #1643: the Steps calibration screen read a SINGLE id and so disagreed with the estimator, which
+ /// resolves an owner per day before reading gravity. Android hit the canonical half of this (it
+ /// hardcoded "my-whoop" and missed a re-added strap's live motion, @kavemang's #1644); the Swift
+ /// screen had the mirror half — it read the ACTIVE id and so missed the canonical history a prior
+ /// import or an earlier strap identity had banked.
+ ///
+ /// Twin of Kotlin `WhoopRepository.gravitySamplesUnion`. It takes the active id as a parameter
+ /// because its repository is not device-scoped; this one already knows `importedReadIds`.
+ func gravitySamplesUnion(from: Int, to: Int, limit: Int = 200_000) async -> [GravitySample] {
+ guard let store = await storeHandle() else { return [] }
+ var lists: [[GravitySample]] = []
+ for id in rawPhysiologyReadIds(store: store) { // active strap FIRST → it wins any shared timestamp
+ lists.append((try? await store.gravitySamples(deviceId: id, from: from, to: to, limit: limit)) ?? [])
+ }
+ return Self.mergeGravityByTs(lists)
+ }
+
+ /// Merge gravity lists into one time-ordered stream, deduped by timestamp with the FIRST list (the
+ /// active strap) winning a tie. A single-id read is returned UNCHANGED, so a single-device install
+ /// performs exactly the read it did before the union existed.
+ ///
+ /// Byte-identical rule to Kotlin `mergeGravityByTs`. Pure + static so the dedup is unit-tested
+ /// without a store — the property that actually matters here is "active wins, nothing double-counts".
+ nonisolated static func mergeGravityByTs(_ lists: [[GravitySample]]) -> [GravitySample] {
+ if lists.count == 1 { return lists[0] }
+ var byTs: [Int: GravitySample] = [:]
+ for list in lists {
+ for s in list where byTs[s.ts] == nil { byTs[s.ts] = s }
+ }
+ return byTs.values.sorted { $0.ts < $1.ts }
+ }
+
+ /// Merge R-R reads without treating a timestamp as a beat identity. Several real beats can share a
+ /// one-second timestamp; only the complete storage identity `(ts, rrMs, seq)` is a duplicate across
+ /// namespaces. The first list is the active strap and therefore wins exact duplicates.
+ nonisolated static func mergeRRByIdentity(_ lists: [[RRInterval]]) -> [RRInterval] {
+ if lists.count == 1 { return lists[0] }
+ struct BeatKey: Hashable { let ts: Int; let rrMs: Int; let seq: Int }
+ var seen = Set()
+ var out: [RRInterval] = []
+ for list in lists {
+ for beat in list where seen.insert(BeatKey(ts: beat.ts, rrMs: beat.rrMs, seq: beat.seq)).inserted {
+ out.append(beat)
+ }
+ }
+ return out.sorted {
+ if $0.ts != $1.ts { return $0.ts < $1.ts }
+ if $0.seq != $1.seq { return $0.seq < $1.seq }
+ if $0.rrMs != $1.rrMs { return $0.rrMs < $1.rrMs }
+ return ($0.ord ?? Int.min) < ($1.ord ?? Int.min)
+ }
+ }
+
/// One day held by two source ids in the SAME bucket, folded into `winner`'s row: `winner` keeps every
/// column it carries (NON-nil — a measured zero is a reading) and `filler` supplies only the ones it
/// left nil. Whole-row first-wins let a hollow row (steps and nothing else) discard a complete one, and
@@ -320,7 +403,11 @@ final class Repository: ObservableObject {
spo2Ir: rawSpo2FromFiller ? filler.spo2Ir : winner.spo2Ir,
// Strap-only, like raw SpO2: an imported winner carries no absolute skin temp, so take the
// filler's rather than let the union blank a value the strap did record (#1636).
- skinTempC: winner.skinTempC ?? filler.skinTempC
+ skinTempC: winner.skinTempC ?? filler.skinTempC,
+ // Part of the SLEEP GROUP, not an independent column (#1801): it describes how the stage
+ // figures above were derived, so it has to come from whichever row supplied them. A plain
+ // `winner ?? filler` would caption the winner's own hypnogram with the filler's staging.
+ sleepHrOnly: sleepFromFiller ? filler.sleepHrOnly : winner.sleepHrOnly
)
}
@@ -336,11 +423,11 @@ final class Repository: ObservableObject {
return byDay.values.sorted { $0.day < $1.day }
}
- /// Sleep sessions across the imported union for a ts range, keeping ALL sessions per day (a nap + a main
+ /// Sleep sessions across every registered WHOOP source for a ts range, keeping ALL sessions per day (a nap + a main
/// night both survive) and dropping only EXACT-duplicate blocks (same start+end) recorded under both
/// union ids. The downstream `mergeSleep`/`userEditedDays` do the per-day collapse, exactly as before.
private func unionSleepSessions(store: WhoopStore, from: Int, to: Int, limit: Int = 4000) async -> [CachedSleepSession] {
- Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: from, to: to, limit: limit))
+ Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawPhysiologyReadIds(store: store), from: from, to: to, limit: limit))
}
/// Computed ("-noop") daily-metric rows across the computed union, DEDUPED per day (active strap's
@@ -355,10 +442,10 @@ final class Repository: ObservableObject {
return byDay.values.sorted { $0.day < $1.day }
}
- /// Computed ("-noop") sleep sessions across the computed union, keeping ALL sessions per day and dropping
+ /// Computed ("-noop") sleep sessions across every registered WHOOP source, keeping ALL sessions per day and dropping
/// only EXACT-duplicate blocks recorded under both computed siblings.
private func unionComputedSleepSessions(store: WhoopStore, from: Int, to: Int, limit: Int = 4000) async -> [CachedSleepSession] {
- Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: from, to: to, limit: limit))
+ Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedReadIds(store: store), from: from, to: to, limit: limit))
}
/// ALL sleep blocks across `ids` for a ts range, concatenated (NOT collapsed to one per day, used by
@@ -525,6 +612,23 @@ final class Repository: ObservableObject {
DailyMetric.lastSkinTempDay(days: days, todayKey: todayKey)
}
+ /// The freshest strictly-prior row with EITHER skin-temp number (#1844), for the surfaces that lead
+ /// with the absolute. See `DailyMetric.lastSkinTempReadingDay`.
+ nonisolated static func lastSkinTempReadingDay(days: [DailyMetric], todayKey: String) -> DailyMetric? {
+ DailyMetric.lastSkinTempReadingDay(days: days, todayKey: todayKey)
+ }
+
+ /// PER-FIELD HRV carry — twin of the above, for a field `lastVitalsDay`'s OR predicate DOES check but
+ /// can still resolve nil on (#1842). See `DailyMetric.lastHrvDay`.
+ nonisolated static func lastHrvDay(days: [DailyMetric], todayKey: String) -> DailyMetric? {
+ DailyMetric.lastHrvDay(days: days, todayKey: todayKey)
+ }
+
+ /// PER-FIELD resting-HR carry — twin of `lastHrvDay`. See `DailyMetric.lastRestingHrDay`.
+ nonisolated static func lastRestingHrDay(days: [DailyMetric], todayKey: String) -> DailyMetric? {
+ DailyMetric.lastRestingHrDay(days: days, todayKey: todayKey)
+ }
+
/// PER-FIELD respiratory carry — twin of `lastSpo2Day`, but STALENESS-BOUNDED to `Baselines.vitalCarryDays`.
/// SpO₂/skin-temp are sparse/imported so last-known-of-any-age is expected; respiratory is nightly, so a
/// weeks-old value shown as the current number is the "Respiratory 15.6 a fortnight later" bug that
@@ -1026,11 +1130,12 @@ final class Repository: ObservableObject {
// UNION the active strap + canonical so the HR trend renders whether the landed day's raw sits under
// the re-added strap (live) or the canonical history. Deduped by ts (active strap wins) so an overlap
// never double-counts; sorted ascending. Single-device install reads one id (byte-identical).
- guard deviceId != canonicalDeviceId else {
+ let ids = rawPhysiologyReadIds(store: store)
+ guard ids.count != 1 else {
return (try? await store.hrSamples(deviceId: deviceId, from: from, to: to, limit: limit)) ?? []
}
var byTs: [Int: HRSample] = [:]
- for id in importedReadIds {
+ for id in ids {
for s in (try? await store.hrSamples(deviceId: id, from: from, to: to, limit: limit)) ?? [] where byTs[s.ts] == nil {
byTs[s.ts] = s
}
@@ -1038,6 +1143,21 @@ final class Repository: ObservableObject {
return byTs.values.sorted { $0.ts < $1.ts }
}
+ /// R-R beats across the active physical WHOOP and canonical history. Exact duplicates are removed
+ /// active-first, while same-timestamp distinct beats remain intact.
+ func rrIntervals(from: Int, to: Int, limit: Int = 8000) async -> [RRInterval] {
+ guard let store = await ensureStore() else { return [] }
+ let ids = rawPhysiologyReadIds(store: store)
+ guard ids.count != 1 else {
+ return (try? await store.rrIntervals(deviceId: deviceId, from: from, to: to, limit: limit)) ?? []
+ }
+ var lists: [[RRInterval]] = []
+ for id in ids {
+ lists.append((try? await store.rrIntervals(deviceId: id, from: from, to: to, limit: limit)) ?? [])
+ }
+ return Self.mergeRRByIdentity(lists)
+ }
+
/// Logical day-start of the most recent day the active device has HR data for, or nil when the store is
/// empty. Lets the Deep Timeline open on a day that actually has data instead of a possibly-empty today
/// right after a history sync , the #597 root cause (the timeline was today-only with no way back).
@@ -1069,19 +1189,11 @@ final class Repository: ObservableObject {
func hrBuckets(from: Int, to: Int, bucketSeconds: Int = 300) async -> [HRBucket] {
guard let store = await ensureStore() else { return [] }
- // UNION the active strap + canonical for the trend chart. Per bucket-start the active strap wins; a
+ // UNION all registered WHOOP straps + canonical for the trend chart. Per bucket-start the active strap wins; a
// raw HR window almost never overlaps between the two namespaces (different time periods), so the
// per-bucket mean stays faithful. Single-device install reads one id (byte-identical).
- guard deviceId != canonicalDeviceId else {
- return (try? await store.hrBuckets(deviceId: deviceId, from: from, to: to, bucketSeconds: bucketSeconds)) ?? []
- }
- var byStart: [Int: HRBucket] = [:]
- for id in importedReadIds {
- for b in (try? await store.hrBuckets(deviceId: id, from: from, to: to, bucketSeconds: bucketSeconds)) ?? [] where byStart[b.ts] == nil {
- byStart[b.ts] = b
- }
- }
- return byStart.values.sorted { $0.ts < $1.ts }
+ return await hrBuckets(deviceIds: rawPhysiologyReadIds(store: store), from: from, to: to,
+ bucketSeconds: bucketSeconds)
}
/// The latest (greatest-ts) non-nil @63 activity class over `[from, to]`, read across the active strap +
@@ -1165,11 +1277,13 @@ final class Repository: ObservableObject {
guard let store = await ensureStore() else { return [] }
let now = Int(Date().timeIntervalSince1970)
let lo = now - days * 86_400, hi = now + 86_400
+ let rawIds = rawPhysiologyReadIds(store: store)
+ let rawComputedIds = rawComputedReadIds(store: store)
// UNION the active strap + canonical (imported) and their computed siblings, keeping ALL blocks (not
// one per day, this view expands split sleeps), but dropping any block that appears under BOTH union
// ids (same start+end key) so a day present in both namespaces isn't double-listed.
- let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: lo, to: hi))
- let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: lo, to: hi))
+ let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawIds, from: lo, to: hi))
+ let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedIds, from: lo, to: hi))
let cal = Calendar.current
func endDay(_ s: CachedSleepSession) -> Date {
cal.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(s.endTs)))
@@ -1187,12 +1301,36 @@ final class Repository: ObservableObject {
/// already chosen the main-night GROUP (the 6.1.1 bridged group) and passes those blocks' starts; we
/// only fetch each one's stored series so the Sleep tab can lay them along the hypnogram's timeline.
/// A start with no stored series is omitted from the result (its key is absent).
- func sessionMotions(starts: [Int]) async -> [Int: [Double]] {
- guard !starts.isEmpty, let store = await ensureStore() else { return [:] }
- // One batched read keyed by startTs, not a single-row SELECT per session start. The store's
- // batched accessor keeps the exact contract of the old loop: starts with no (or an empty) series
- // are omitted from the result.
- return (try? await store.sessionMotions(deviceId: computedDeviceId, sessionStarts: starts)) ?? [:]
+ func sessionMotions(sessions: [CachedSleepSession]) async -> [Int: [Double]] {
+ guard !sessions.isEmpty, let store = await ensureStore() else { return [:] }
+ let rawIds = rawPhysiologyReadIds(store: store)
+ let computedIds = rawComputedReadIds(store: store)
+ var out: [Int: [Double]] = [:]
+ for session in sessions where out[session.startTs] == nil {
+ // CachedSleepSession has no provenance field. Re-resolve the exact visible block using the
+ // same imported-wins order as allSleepSessions, then probe its computed owner first.
+ var owner: String?
+ for id in rawIds + computedIds {
+ let rows = (try? await store.sleepSessions(deviceId: id, from: session.startTs,
+ to: session.startTs, limit: 4)) ?? []
+ if rows.contains(where: { $0.startTs == session.startTs && $0.endTs == session.endTs }) {
+ owner = id
+ break
+ }
+ }
+ let ownerComputed = owner.map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" }
+ let sources = ([ownerComputed].compactMap { $0 } + computedIds).reduce(into: [String]()) {
+ if !$0.contains($1) { $0.append($1) }
+ }
+ for id in sources {
+ if let motion = (try? await store.sessionMotion(deviceId: id, sessionStart: session.startTs)) ?? nil,
+ !motion.isEmpty {
+ out[session.startTs] = motion
+ break
+ }
+ }
+ }
+ return out
}
/// The user's learned habitual midsleep (local time-of-day seconds), or nil under
@@ -1209,10 +1347,12 @@ final class Repository: ObservableObject {
guard let store = await ensureStore() else { return nil }
let now = Int(Date().timeIntervalSince1970)
let lo = now - days * 86_400, hi = now + 86_400
- // UNION active strap + canonical (imported) and their computed siblings, de-duplicating identical
- // blocks recorded under both ids so a day present in both namespaces doesn't double-weight the learner.
- let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: lo, to: hi))
- let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: lo, to: hi))
+ // Use the SAME all-registered-WHOOP source set as allSleepSessions. Otherwise a removed/replaced
+ // strap's visible retained nights would not teach the selector that chooses their main block.
+ let rawIds = rawPhysiologyReadIds(store: store)
+ let rawComputedIds = rawComputedReadIds(store: store)
+ let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawIds, from: lo, to: hi))
+ let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedIds, from: lo, to: hi))
let offsetSec = TimeZone.current.secondsFromGMT()
let blocks = (imported + computed).compactMap { s -> SleepStageTotals.HistoryBlock? in
let start = s.effectiveStartTs, end = s.endTs
@@ -1686,11 +1826,9 @@ final class Repository: ObservableObject {
func timelineSeries(metric: TimelineMetric, from: Int, to: Int,
targetPoints: Int = 600, source: String? = nil) async -> TimelineSeries {
guard to > from, let store = await ensureStore() else { return .empty }
- // Default (no explicit source) → the user's own strap, UNIONed with the canonical "my-whoop" so the
- // Deep Timeline renders whether the landed day's raw sits under the re-added strap or the canonical
- // history. An explicit source (a per-source page) reads that source verbatim. `unionIds` is one id
- // on a single-device install (byte-identical) and dedups raw by ts (active strap wins).
- let unionIds: [String] = source.map { [$0] } ?? importedReadIds
+ // Default (no explicit source) → the complete worn WHOOP timeline: active, every registered prior
+ // strap, then canonical history. An explicit per-source page still reads that source verbatim.
+ let unionIds: [String] = source.map { [$0] } ?? rawPhysiologyReadIds(store: store)
let bucket = Self.timelineBucketSeconds(spanSeconds: to - from, targetPoints: targetPoints)
let isRaw = bucket <= 1
@@ -2126,7 +2264,9 @@ final class Repository: ObservableObject {
/// byte-identical. The flag is forwarded to the non-strap `series(...)` delegation below so every source
/// path honours it.
func exploreSeries(key: String, source: String, days: Int = 4000, fullHistory: Bool = false) async -> [(day: String, value: Double)] {
- guard source == "my-whoop" else { return await series(key: key, source: source, days: days, fullHistory: fullHistory) }
+ guard source == "my-whoop" else {
+ return Self.oneSkinTempScale(key: key, await series(key: key, source: source, days: days, fullHistory: fullHistory))
+ }
guard let store = await ensureStore() else { return [] }
let now = Date()
let from = fullHistory ? "0000-01-01" : Self.dayString(now.addingTimeInterval(-Double(days) * 86_400))
@@ -2153,7 +2293,29 @@ final class Repository: ObservableObject {
for p in (try? await store.metricSeries(deviceId: id, key: key, from: from, to: to)) ?? [] { byDay[p.day] = p.value }
}
- return byDay.sorted { $0.key < $1.key }.map { (day: $0.key, value: $0.value) }
+ return Self.oneSkinTempScale(key: key, byDay.sorted { $0.key < $1.key }.map { (day: $0.key, value: $0.value) })
+ }
+
+ /// #1705: reduce a `skin_temp` window to a single scale; every other key passes through untouched.
+ ///
+ /// The key is bimodal — a CSV import writes ABSOLUTE °C where the computed layers write a baseline
+ /// DEVIATION, so one window can hold both. Every consumer of an Explore series aggregates it
+ /// (min/max/mean, the window-over-window delta, the correlation scan), and those are arithmetic
+ /// across two scales: on the reported account a few ~34 °C readings lifted a should-be-near-zero
+ /// average past a full degree, and the mean then read below 20 so it was labelled Δ°C.
+ ///
+ /// Applied on BOTH of `exploreSeries`' return paths, deliberately. Today the only `skin_temp`
+ /// descriptor is `my-whoop`, so the delegating path is unreachable for it — but a guard that is
+ /// correct only because of a fact in another file is how this bug happened in the first place.
+ /// `series(day:value:)` is ordered ascending by day, which `dominantKind` relies on.
+ private static func oneSkinTempScale(
+ key: String,
+ _ series: [(day: String, value: Double)]
+ ) -> [(day: String, value: Double)] {
+ guard key == "skin_temp",
+ let keep = SkinTempDisplay.dominantKind(valuesAscendingByDay: series.map(\.value))
+ else { return series }
+ return series.filter { SkinTempDisplay.kind(of: $0.value) == keep }
}
/// Every catalog metric's Explore series at once, memoized — the cross-catalog scan behind the
@@ -2432,13 +2594,16 @@ final class Repository: ObservableObject {
guard let store = await ensureStore() else { return [] }
let now = Int(Date().timeIntervalSince1970)
let lo = now - days * 86_400, hi = now + 86_400
- // UNION the active strap + canonical (and their computed siblings) so workouts banked under the
- // canonical "my-whoop" before a re-add still show, alongside the re-added strap's live workouts.
+ // UNION every registered WHOOP + canonical (and computed siblings) so workouts banked before a
+ // re-add remain visible alongside every retained strap's live workouts.
// De-dup identical same-source rows that appear under both union ids by natural key (the cross-SOURCE
// dedup below only collapses strap-vs-Apple twins, not a row present in two strap namespaces).
var rows: [WorkoutRow] = []
- for id in importedReadIds { rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] }
- for id in computedReadIds { rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] }
+ let rawIds = rawPhysiologyReadIds(store: store)
+ for id in rawIds { rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] }
+ for id in rawIds.map({ $0.hasSuffix("-noop") ? $0 : $0 + "-noop" }) {
+ rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? []
+ }
rows += (try? await store.workouts(deviceId: "apple-health", from: lo, to: hi, limit: 5000)) ?? []
// Imported lifting sessions (Hevy / Liftosaur) live under their own "lifting" source.
rows += (try? await store.workouts(deviceId: "lifting", from: lo, to: hi, limit: 5000)) ?? []
@@ -3050,7 +3215,11 @@ final class Repository: ObservableObject {
}
}
-private extension DailyMetric {
+// `internal`, not `private`: `DailySkinTempAbsoluteCarryTests` exercises these two directly, and a
+// `private` extension is invisible even to `@testable import`. They stayed private because the tests
+// that call them have never compiled (StrandTests runs only in the on-demand app-build), so nothing
+// ever demanded the wider access. Still confined to this module.
+extension DailyMetric {
/// A copy of self where every nil field is backfilled from `fallback`. Used by the field-by-field
/// daily merge so an imported export keeps its own values while a computed row fills the gaps it
/// doesn't carry (e.g. on-device Charge / skin-temp deviation / activity totals).
@@ -3080,7 +3249,10 @@ private extension DailyMetric {
avgSdnn: avgSdnn ?? fallback.avgSdnn,
// On-device only (imports never carry it), so an imported row's nil is backfilled from the
// computed fallback — otherwise the night's absolute would be lost. (#1636)
- skinTempC: skinTempC ?? fallback.skinTempC
+ skinTempC: skinTempC ?? fallback.skinTempC,
+ // Same shape, same reason (#1801): only a scoring pass knows how the night was staged, so an
+ // imported row's nil takes the computed answer rather than erasing it.
+ sleepHrOnly: sleepHrOnly ?? fallback.sleepHrOnly
)
}
@@ -3110,7 +3282,11 @@ private extension DailyMetric {
spo2Red: spo2Red, // non-sleep field: preserved as-is (#93)
spo2Ir: spo2Ir,
avgSdnn: avgSdnn, // non-sleep (HRV) field: preserved as-is
- skinTempC: skinTempC // non-sleep (thermal) field: preserved as-is (#1636)
+ skinTempC: skinTempC, // non-sleep (thermal) field: preserved as-is (#1636)
+ // MOVES with the sleep columns, unlike the others here: it describes how the very stage
+ // figures being taken were derived, so leaving `self`'s behind would caption the source's
+ // hypnogram with the import's staging — and an import's is always nil. (#1801)
+ sleepHrOnly: source.sleepHrOnly
)
}
}
diff --git a/Strand/Data/SkinTempBackfillWalker.swift b/Strand/Data/SkinTempBackfillWalker.swift
new file mode 100644
index 0000000000..55b5131783
--- /dev/null
+++ b/Strand/Data/SkinTempBackfillWalker.swift
@@ -0,0 +1,264 @@
+import Foundation
+import StrandAnalytics
+import WhoopStore
+import WhoopProtocol
+
+/// #1853: the Apple walker that backfills `skinTempC` for nights the 21-night `analyzeRecent` rescore
+/// window never reaches. The pure half (session attribution, the plan, rule 3) lives in
+/// `StrandAnalytics/SkinTempBackfill.swift`; this file is the I/O layer that reads from / writes to
+/// `WhoopStore`.
+///
+/// The walker mirrors the scoring pass's per-night reads (same window, same stream limits, same
+/// device-family / anchor / tolerance resolution) and calls the SAME `skinTempFunnel` the engine
+/// uses, so a backfilled absolute is byte-identical to what the scoring pass would have stored.
+///
+/// Trigger: ON-DEMAND from the Test Centre (the issue's "diagnostic first" + safest trigger). Doing
+/// it automatically on upgrade risks a long first launch on a deep history. The caller decides how
+/// many pages to run; one page is 50 nights by default.
+///
+/// Non-negotiable: a fill-only write (`WhoopStore.fillSkinTempC`) that can only fill a NULL, never an
+/// upsert of a rebuilt row. `skinTempDevC` and every other scored field are untouched.
+/// An `actor`, deliberately, not a `@MainActor` class. This walks up to ten pages of fifty nights and
+/// reads three streams for each, and while every `await` yields the main thread, each RESUMPTION and all
+/// the per-night work between them would land on it. There is no UI state here - the store is an actor,
+/// `DeviceRegistryStore` is a Sendable struct, and the result types are Sendable - so nothing needs the
+/// main actor, and a long diagnostic pass has no business competing with the UI for it.
+actor SkinTempBackfillWalker {
+
+ /// One night that filled — the day and the re-derived absolute.
+ struct FilledNight: Sendable {
+ let day: String
+ let skinTempC: Double
+ }
+
+ /// One night that declined — the day and the reason, for the diagnostic report.
+ struct DeclinedNight: Sendable {
+ let day: String
+ let reason: String
+ }
+
+ /// The result of one backfill pass (one or more pages).
+ struct PassResult: Sendable {
+ /// Nights that filled (day → absolute °C).
+ let filled: [FilledNight]
+ /// Nights that declined (day → reason), for the diagnostic report.
+ let declined: [DeclinedNight]
+ /// Nights skipped because no raw `skinTempSample` rows exist for the window (the data is gone,
+ /// not the backfill's fault — see the issue's "Diagnostic first" note).
+ let noRawData: [String]
+ /// Whether another page might yield more fills (false when the last page was empty).
+ let moreRemaining: Bool
+
+ var totalAttempted: Int { filled.count + declined.count + noRawData.count }
+ }
+
+ private let store: WhoopStore
+ private let computedId: String
+
+ init(store: WhoopStore) {
+ self.store = store
+ self.computedId = Repository.whoopSource + "-noop"
+ }
+
+ /// Run one page of the backfill. The caller calls this repeatedly with increasing `page` until
+ /// `moreRemaining` is false or a budget is reached.
+ ///
+ /// - Parameters:
+ /// - page: 0-based page index (rule 2: page the candidates so the sweep doesn't latch on the
+ /// oldest N).
+ /// - pageSize: nights per page (default 50).
+ /// - windowAnchorRaw: the per-device WHOOP 4.0 anchor learned from the CURRENT scoring window
+ /// (rule 3). The caller obtains this from the same window-wide scan the scoring pass uses.
+ /// nil for a 5/MG or when the current window couldn't learn one.
+ /// - tzOffsetSeconds: seconds east of UTC, for local-day → unix-seconds conversion.
+ func runPage(page: Int, pageSize: Int = 50,
+ windowAnchorRaw: Double?,
+ tzOffsetSeconds: Int) async -> PassResult {
+ // 1. Find candidate nights: computed rows with a deviation but no absolute.
+ let candidates = await candidateNights()
+ let pageCandidates = SkinTempBackfill.page(candidates, page: page, pageSize: pageSize)
+ if pageCandidates.isEmpty {
+ return PassResult(filled: [], declined: [], noRawData: [], moreRemaining: false)
+ }
+
+ // 2. Resolve the device family + worn tolerance for the owner (single-owner install: the
+ // canonical "my-whoop"). The registry is read once, mirroring the scoring pass.
+ let registry = DeviceRegistryStore(dbQueue: store.registryWriter)
+ let regDevices = (try? registry.all()) ?? []
+ let activeId = (try? registry.activeDeviceId()) ?? Repository.whoopSource
+
+ var filled: [FilledNight] = []
+ var declined: [DeclinedNight] = []
+ var noRawData: [String] = []
+
+ for candidate in pageCandidates {
+ // Resolve the day owner for this night (same resolver the scoring pass uses).
+ let owner = await IntelligenceEngine.resolveDayOwner(
+ day: candidate.day, from: candidate.from, to: candidate.to,
+ store: store, devices: regDevices, activeId: activeId,
+ registry: registry, fallbackDeviceId: Repository.whoopSource)
+
+ let family = IntelligenceEngine.skinTempFamily(forOwner: owner, devices: regDevices)
+ let tolerance = IntelligenceEngine.skinTempWornToleranceSec(forOwner: owner, devices: regDevices)
+
+ // Read the raw streams for this night's window — same limits the scoring pass uses.
+ let skin = (try? await store.skinTempSamples(
+ deviceId: owner, from: candidate.from, to: candidate.to,
+ limit: StreamReadCap.skin)) ?? []
+ if skin.isEmpty {
+ noRawData.append(candidate.day)
+ continue
+ }
+ let hr = (try? await store.hrSamples(
+ deviceId: owner, from: candidate.from, to: candidate.to,
+ limit: StreamReadCap.hr)) ?? []
+ // Read stored sleep sessions for the window (the backfill takes the night's stored sleep
+ // window, no re-detection — the issue's "take the night's stored sleep window from
+ // sleepSession").
+ let persisted = (try? await store.sleepSessions(
+ deviceId: owner, from: candidate.from, to: candidate.to,
+ limit: 4000)) ?? []
+ let sessions = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) }
+
+ // Compute the local-midnight unix seconds for this day (the day key is local, so the
+ // midnight is the UTC midnight of (dayStart + tzOffset)). The scoring pass uses the same
+ // shift: `dayString(ts, offsetSec:) == day` ⇔ `(ts + offsetSec) ∈ [dayStart, +86400)`.
+ let dayStart = Self.localMidnightUnix(forDay: candidate.day, tzOffsetSeconds: tzOffsetSeconds)
+
+ let result = SkinTempBackfill.computeNight(
+ candidate: candidate,
+ sessions: sessions,
+ hr: hr,
+ skinTemp: skin,
+ family: family,
+ windowAnchorRaw: windowAnchorRaw,
+ dayStart: dayStart,
+ wornToleranceSec: tolerance)
+
+ if let abs = result.skinTempC {
+ // Fill-only write: can only fill a NULL, never overwrite a measured value.
+ _ = try? await store.fillSkinTempC(deviceId: computedId, day: candidate.day, skinTempC: abs)
+ filled.append(FilledNight(day: candidate.day, skinTempC: abs))
+ } else {
+ declined.append(DeclinedNight(day: candidate.day, reason: result.declineReason ?? "unknown"))
+ }
+ }
+
+ let moreRemaining = pageCandidates.count == pageSize
+ return PassResult(filled: filled, declined: declined, noRawData: noRawData,
+ moreRemaining: moreRemaining)
+ }
+
+ /// Find computed `DailyMetric` rows that have a `skinTempDevC` but no `skinTempC` — the nights
+ /// the backfill can fill. Oldest-first (the issue pages oldest→newest so the sweep reaches the
+ /// newer nights that can fill).
+ private func candidateNights() async -> [SkinTempBackfill.NightCandidate] {
+ // Read all computed rows (the backfill window is unbounded — the issue's "how far back" is
+ // every night with samples). A wide day range covers the full local-history span.
+ let rows = (try? await store.dailyMetrics(
+ deviceId: computedId, from: "0000-01-01", to: "9999-12-31")) ?? []
+ let tzOffset = TimeZone.current.secondsFromGMT()
+ let now = Int(Date().timeIntervalSince1970)
+ // Candidate: has a deviation (the night was scored) but no absolute (the column was null).
+ // Build the night's read window the same way the scoring pass does.
+ return rows
+ .filter { $0.skinTempDevC != nil && $0.skinTempC == nil }
+ .sorted { $0.day < $1.day }
+ .map { row in
+ let dayStart = Self.localMidnightUnix(forDay: row.day, tzOffsetSeconds: tzOffset)
+ let from = dayStart - StreamReadCap.lookbackSeconds
+ let to = min(dayStart + 86_400, now) // don't read past now for today's partial night
+ return SkinTempBackfill.NightCandidate(day: row.day, from: from, to: to,
+ owner: Repository.whoopSource)
+ }
+ }
+
+ /// Local-midnight unix seconds for a yyyy-MM-dd day key. The day key is LOCAL, so midnight is the
+ /// UTC midnight of `(dayStart + tzOffset)`: `dayString(ts, offsetSec:) == day` ⇔
+ /// `(ts + offsetSec) ∈ [dayStart, +86400)`, so `dayStart = utcMidnight(day) - tzOffset`.
+ static func localMidnightUnix(forDay day: String, tzOffsetSeconds: Int) -> Int {
+ // Parse the yyyy-MM-dd key to a UTC midnight, then shift by the tz offset to get local midnight.
+ let parts = day.split(separator: "-")
+ guard parts.count == 3,
+ let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]) else { return 0 }
+ var comps = DateComponents()
+ comps.year = y; comps.month = m; comps.day = d
+ comps.hour = 0; comps.minute = 0; comps.second = 0
+ comps.timeZone = TimeZone(identifier: "UTC")
+ let utcMidnight = Calendar(identifier: .gregorian).date(from: comps) ?? Date()
+ return Int(utcMidnight.timeIntervalSince1970) - tzOffsetSeconds
+ }
+
+ // MARK: - Self-contained entry point (the call site)
+
+ /// The aggregate result of a full backfill pass (one or more pages), for the Test Centre report.
+ struct BackfillResult: Sendable {
+ let filled: [FilledNight]
+ let declined: [DeclinedNight]
+ let noRawData: [String]
+ /// The window anchor that was used (nil for a 5/MG or when the current window couldn't learn one).
+ let windowAnchorRaw: Double?
+ /// Whether the pass reached the end (false when a page was empty) or hit the page budget.
+ let reachedEnd: Bool
+ var filledCount: Int { filled.count }
+ var declinedCount: Int { declined.count }
+ var noRawDataCount: Int { noRawData.count }
+ }
+
+ /// Run the full backfill: resolve the WHOOP 4.0 window anchor from the CURRENT scoring window
+ /// (rule 3 — the same window-wide scan the engine uses), then page through the candidate nights
+ /// until a page is empty or `maxPages` is reached. This is the single entry point the Test Centre
+ /// action calls; it owns the anchor scan so the call site does not duplicate engine internals.
+ ///
+ /// `maxDays` mirrors the scoring pass's window (default 21). `maxPages` caps the per-pass cost
+ /// (default 10 pages × 50 nights = 500 nights, plenty for any install).
+ func runBackfill(maxDays: Int = 21, maxPages: Int = 10) async -> BackfillResult {
+ let tzOffset = TimeZone.current.secondsFromGMT()
+ let now = Int(Date().timeIntervalSince1970)
+ let nowLocalMidnight = Self.localMidnightUnix(
+ forDay: Self.dayKey(now: now, tzOffsetSeconds: tzOffset), tzOffsetSeconds: tzOffset)
+ // Rule 3: the anchor comes from the CURRENT scoring window, learned window-wide (not per-night).
+ // Same window bounds the engine's `analyzeRecent` uses for `skinAnchorScanFrom`/`skinAnchorScanTo`.
+ let scanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - StreamReadCap.lookbackSeconds
+ let scanTo = nowLocalMidnight + 18 * 3_600
+ let registry = DeviceRegistryStore(dbQueue: store.registryWriter)
+ let activeId = (try? registry.activeDeviceId()) ?? Repository.whoopSource
+ let regDevices = (try? registry.all()) ?? []
+ let family = IntelligenceEngine.skinTempFamily(forOwner: activeId, devices: regDevices)
+ // Only a 4.0 needs the anchor; a 5/MG (centidegree path) passes nil and proceeds.
+ let windowAnchorRaw: Double?
+ if family == .whoop4 {
+ let windowSkin = (try? await store.skinTempSamples(
+ deviceId: activeId, from: scanFrom, to: scanTo,
+ limit: StreamReadCap.skin)) ?? []
+ windowAnchorRaw = Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { $0.raw })
+ } else {
+ windowAnchorRaw = nil
+ }
+ var filled: [FilledNight] = []
+ var declined: [DeclinedNight] = []
+ var noRawData: [String] = []
+ var reachedEnd = false
+ for page in 0.. String {
+ let comps = Calendar(identifier: .gregorian).dateComponents(
+ [.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(now + tzOffsetSeconds)))
+ let y = comps.year ?? 0, m = comps.month ?? 0, d = comps.day ?? 0
+ return String(format: "%04d-%02d-%02d", y, m, d)
+ }
+}
diff --git a/Strand/Data/SleepLayoutPrefs.swift b/Strand/Data/SleepLayoutPrefs.swift
index 6980e65358..75e6cab182 100644
--- a/Strand/Data/SleepLayoutPrefs.swift
+++ b/Strand/Data/SleepLayoutPrefs.swift
@@ -29,6 +29,7 @@ import SwiftUI
enum SleepSection: String, CaseIterable, Identifiable {
case sleepMarks
case stages
+ case bodyClock
case nightDetail
case sleepDebt
case stagesVsTypical
@@ -41,6 +42,7 @@ enum SleepSection: String, CaseIterable, Identifiable {
switch self {
case .sleepMarks: return String(localized: "Sleep marks")
case .stages: return String(localized: "Stages")
+ case .bodyClock: return String(localized: "Body clock")
case .nightDetail: return String(localized: "Night detail")
case .sleepDebt: return String(localized: "Sleep-debt ledger")
case .stagesVsTypical: return String(localized: "Stages vs typical")
@@ -53,7 +55,7 @@ enum SleepSection: String, CaseIterable, Identifiable {
/// with Stages for now — it's drawn inside the stages hero; making it an independently arrangeable
/// card is a follow-up that requires hoisting the hero's edit/delete callbacks.)
static let defaultOrder: [SleepSection] = [
- .sleepMarks, .stages, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration,
+ .sleepMarks, .stages, .bodyClock, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration,
]
}
diff --git a/Strand/Data/SleepMark.swift b/Strand/Data/SleepMark.swift
index 0dc79a9e61..9ad460c965 100644
--- a/Strand/Data/SleepMark.swift
+++ b/Strand/Data/SleepMark.swift
@@ -107,10 +107,7 @@ struct SleepMark: Equatable, Sendable {
/// Device-locale clock for the log line ("11:42 PM" / "23:42") — follows the 12-/24-hour setting,
/// matching the Sleep screen's Asleep/Woke row.
- private static let clockFormatter: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.setLocalizedDateFormatFromTemplate("jmm")
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var clockFormatter: DateFormatter { AppClock.hourMinuteFormatter() }
}
diff --git a/Strand/Data/Units.swift b/Strand/Data/Units.swift
index b5dd1d90ac..04d4a6b5cb 100644
--- a/Strand/Data/Units.swift
+++ b/Strand/Data/Units.swift
@@ -1,24 +1,25 @@
import Foundation
+import StrandAnalytics
// MARK: - Unit system preference
//
// NOOP stores EVERYTHING in SI (km, kg, cm, °C) — the importers normalise on the way in, so this is a
// purely cosmetic, display-only layer. There is no data migration and nothing on disk changes when the
-// user flips this. We keep one Metric/Imperial switch for length+mass with a SEPARATE temperature
-// override, because plenty of people think in kg/cm but still read body temperature in °F (and vice
-// versa). Default is Metric — most of the world, and it matches what we store.
+// user flips this. Body measurements and exercise distance each have their own Metric/Imperial choice,
+// with a SEPARATE temperature override, because regional conventions commonly mix pounds with kilometres
+// (or kg/cm with °F). Default is Metric — most of the world, and it matches what we store.
//
// Persisted via @AppStorage (UserDefaults), the same mechanism every other macOS NOOP preference uses.
// The Android side mirrors this exactly in Units.kt + NoopPrefs.
-/// The length+mass unit system. Temperature has its own override (see `UnitPrefs.temperature`).
+/// A Metric/Imperial display choice. Body measurements and exercise distance persist separate values.
enum UnitSystem: String, CaseIterable, Identifiable {
case metric
case imperial
var id: String { rawValue }
- /// "follow the system" pairs temperature with the length/mass choice; an explicit case lets the
- /// user pin °C or °F independently of whether distances are in km or miles.
+ /// "Follow body measurements" pairs temperature with the body choice; an explicit case lets the
+ /// user pin °C or °F independently.
var temperatureMatching: TemperatureUnit { self == .imperial ? .fahrenheit : .celsius }
}
@@ -78,12 +79,20 @@ enum HrvWindow: String, CaseIterable, Identifiable {
var label: String { self == .deep ? "Deep sleep" : "Whole night" }
}
-/// UserDefaults keys for the two unit preferences. Public-ish (internal) so `SettingsView`'s
-/// `@AppStorage(UnitPrefs.systemKey)` and the formatter read the SAME key — no drift.
+/// UserDefaults keys and backwards-compatible preference resolution.
enum UnitPrefs {
+ /// The original combined preference now owns body measurements. Keeping its key preserves every
+ /// existing user's kg/cm or lb/ft-in choice without a migration.
static let systemKey = "units.system"
- /// Temperature override. Empty string = "match the length/mass system" (the default).
+ /// Exercise distance + pace. Empty/unset follows `systemKey`, preserving the pre-#1913 behaviour.
+ static let distanceSystemKey = "units.distance"
+ /// Temperature override. Empty string = "follow body measurements" (the default).
static let temperatureKey = "units.temperature"
+
+ /// #1846: which skin-temp number the cards lead with — absent/`""` = a temperature (the default), or
+ /// `SkinTempDisplay.Kind.deviation.rawValue` to lead with the ±baseline move. Display-only; nothing
+ /// stored ever changes. Same key string as the Android `units.skinTempDisplay` pref.
+ static let skinTempDisplayKey = "units.skinTempDisplay"
/// Effort display scale (#268). Stored raw is an `EffortScale` rawValue; an unset/unknown value
/// resolves to `.hundred` (NOOP's native axis). Mirrored on Android by NoopPrefs("effort.scale").
static let effortScaleKey = "effort.scale"
@@ -115,13 +124,18 @@ enum UnitPrefs {
? true : UserDefaults.standard.bool(forKey: liveActivityKey)
}
- /// Resolve the stored raw values into a concrete temperature unit, applying the
- /// "match the system" default when no explicit override is set.
+ /// Resolve temperature, following body measurements when no explicit override is set.
static func resolveTemperature(system: UnitSystem, override raw: String) -> TemperatureUnit {
if let explicit = TemperatureUnit(rawValue: raw) { return explicit }
return system.temperatureMatching
}
+ /// Resolve the exercise-distance system. Existing installs have no distance key, so an empty or
+ /// unknown value follows the original combined preference until the user chooses independently.
+ static func resolveDistance(system: UnitSystem, override raw: String) -> UnitSystem {
+ UnitSystem(rawValue: raw) ?? system
+ }
+
/// Resolve the stored Effort-scale raw value, defaulting to NOOP's native 0–100 axis.
static func resolveEffortScale(_ raw: String) -> EffortScale {
EffortScale(rawValue: raw) ?? .hundred
@@ -195,6 +209,15 @@ enum UnitFormatter {
system == .imperial ? "mi" : "km"
}
+ /// Format speed stored in kilometres/hour in the exercise-distance system.
+ static func speedFromKilometersPerHour(_ kmh: Double?, system: UnitSystem) -> String? {
+ guard let kmh, kmh.isFinite, kmh >= 0 else { return nil }
+ switch system {
+ case .metric: return oneDecimal(kmh) + " km/h"
+ case .imperial: return oneDecimal(kmToMiles(kmh)) + " mph"
+ }
+ }
+
// MARK: Mass (stored kg)
/// kg → pounds.
@@ -252,13 +275,43 @@ enum UnitFormatter {
}
}
+ /// °C delta → °F delta: a DIFFERENCE scales by 9/5 with NO +32 offset (the offset cancels between
+ /// the two absolute temperatures a delta is made of). Exposed as a Double because callers that need
+ /// locale-aware decimals — the illness-signal label formats through `AppLanguage.activeLocale` so a
+ /// German reader sees "0,7" — have to do their own formatting and would otherwise inline the 9/5,
+ /// scattering the one rule that must not drift.
+ static func celsiusDeltaToFahrenheit(_ dc: Double) -> Double { dc * 9.0 / 5.0 }
+
/// Format a temperature DEVIATION (a ±Δ°C, e.g. the skin-temp deviation pipeline). A delta scales by
/// 9/5 but does NOT add the +32 offset — that would be wrong for a difference.
static func temperatureDeltaFromCelsius(_ dc: Double, unit: TemperatureUnit, decimals: Int = 1) -> String {
switch unit {
case .celsius: return decimalString(dc, decimals) + " °C"
- case .fahrenheit: return decimalString(dc * 9.0 / 5.0, decimals) + " °F"
+ case .fahrenheit: return decimalString(celsiusDeltaToFahrenheit(dc), decimals) + " °F"
+ }
+ }
+
+ /// The skin-temperature phrase used by the illness-signal label — number AND unit chip, ready to
+ /// interpolate into one `%@`.
+ ///
+ /// #111/#622: the field is BIMODAL. An imported night carries an ABSOLUTE wrist °C, a live one a
+ /// signed DEVIATION from baseline, and they share a column — so the +32 offset belongs to only one
+ /// of them, and the chip is "°F" for one and "Δ°F" for the other. Both come from `SkinTempDisplay`,
+ /// the same authority the Today and Health tiles use.
+ ///
+ /// The NUMBER is formatted here rather than by `SkinTempDisplay.format` purely so it can honour
+ /// [locale]: the banner formats through `AppLanguage.activeLocale`, and a German reader must see
+ /// "0,7" where the package's plain `String(format:)` would give "0.7". Pass nil for POSIX decimals.
+ static func skinTempSignalPhrase(_ value: Double, fahrenheit: Bool, locale: Locale?) -> String {
+ let kind = SkinTempDisplay.kind(of: value)
+ let shown: Double
+ if fahrenheit {
+ shown = kind == .absolute ? celsiusToFahrenheit(value) : celsiusDeltaToFahrenheit(value)
+ } else {
+ shown = value
}
+ let number = String(format: kind == .absolute ? "%.1f" : "%+.1f", locale: locale, shown)
+ return number + " " + SkinTempDisplay.unitSymbol(kind: kind, fahrenheit: fahrenheit)
}
/// Temperature unit label only. "°C" / "°F".
diff --git a/Strand/Data/UpdateStore.swift b/Strand/Data/UpdateStore.swift
index af0c1ac8ae..b0a6b27077 100644
--- a/Strand/Data/UpdateStore.swift
+++ b/Strand/Data/UpdateStore.swift
@@ -15,6 +15,10 @@ struct UpdateItem: Identifiable, Codable, Equatable {
case whatsNew // a release note (seeded from AppChangelog on first run after an update)
case reading // new data arrived (e.g. "N days backfilled") — links to Trends
case strapAlert // a strap-side heads-up (low battery, sync) — informational
+ /// A NEWER RELEASE exists that this install does not have (#1659). Deliberately not `.whatsNew`:
+ /// that one means "here is what you just got", this one means "here is what you have not got yet",
+ /// and giving them the same row would make the bell ambiguous at the moment it matters most.
+ case newVersion
}
let id: UUID
@@ -131,7 +135,10 @@ final class UpdateStore: ObservableObject {
}
private static func isInformational(_ kind: UpdateItem.Kind) -> Bool {
- kind == .reading || kind == .whatsNew
+ // `.newVersion` is informational: it dedupes and is evictable like the others. It cannot spam on
+ // its own (UpdateAvailability posts once per version), but a user who ignores several releases
+ // should not have them crowd out the actionable rows.
+ kind == .reading || kind == .whatsNew || kind == .newVersion
}
/// Trim the informational backlog to the newest `maxItems`. Actionable rows (`.dismissedCard`,
diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift
index 28d5e0280d..96eb35c357 100644
--- a/Strand/Liquid/LiquidTodayView.swift
+++ b/Strand/Liquid/LiquidTodayView.swift
@@ -17,6 +17,8 @@ import WhoopStore
import StrandAnalytics
struct LiquidTodayView: View {
+ @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue
+ private var dayCycleMode: DayCycleMode { DayCycleMode.persisted(dayCycleModeRaw) }
@EnvironmentObject var repo: Repository
@EnvironmentObject var router: NavRouter
@EnvironmentObject var profile: ProfileStore
@@ -73,6 +75,8 @@ struct LiquidTodayView: View {
// sheets / expanders
@State private var guideSection: ScoreSection?
@State private var customizationDestination: TodayCustomizationDestination?
+ /// #1862: the optional Coach launcher sheet. Presentation state only — opening it requests nothing.
+ @State private var showCoachLauncher = false
@State private var showSettings = false
@State private var synthesisExpanded = false
@State private var showLiveSession = false
@@ -99,6 +103,11 @@ struct LiquidTodayView: View {
@State private var kSparks: [String: [(String, Double)]] = [:]
private var enabledKeyMetrics: [KeyMetric] { KeyMetricPrefs.decodeEnabled(keyMetricsRaw) }
+ /// #1001: TODAY's in-progress Effort, scored live in `load()` over the same window this view already
+ /// resolves for its other reads. nil for a navigated past day, and nil when the scorer has too few
+ /// readings — every Effort read-out then falls back to the stored row rather than a fabricated value.
+ @State private var liveTodayStrain: Double?
+
// day navigation (0 = today, 1 = yesterday, …)
@State private var selectedDayOffset = 0
@State private var showDayPicker = false
@@ -114,6 +123,9 @@ struct LiquidTodayView: View {
/// resolved in body — body rescans repo.days ~23× per pass, and this cache keeps that read O(1).
@State private var cachedVitalsDay: DailyMetric?
@State private var cachedRespDay: DailyMetric?
+ @State private var cachedHrvDay: DailyMetric?
+ @State private var cachedRestingHrDay: DailyMetric?
+ @State private var cachedSkinTempReadingDay: DailyMetric?
/// The Charge hero's resolved state (#543 carry + the honest label), resolved ONCE in load() alongside
/// the other caches. It composes `TodayView.lastScoredRecoveryDay`, which is O(days) — exactly the scan
/// this cache exists to keep out of body. Never resolved in body.
@@ -178,6 +190,25 @@ struct LiquidTodayView: View {
/// The prior-day RESPIRATORY carry (#1331): staleness-bounded, so a recent missed night reads the last
/// real value while a weeks-old one honestly shows "No Data". Non-nil only at offset 0.
private var respDay: DailyMetric? { cachedRespDay }
+
+ /// PER-FIELD HRV / resting-HR carries (#1842), read O(1) from the cache like `vitalsDay`. `vitalsDay`'s
+ /// predicate is an OR across HRV / resting-HR / respiratory, so it resolves the freshest row with ANY of
+ /// them — a respiratory-only row blanks HRV and Resting HR on both the vitals card and the Key Metrics
+ /// tiles. Twins of `DailyMetric.lastHrvDay` / `lastRestingHrDay`; mirror the Android per-field rows.
+ private var hrvDay: DailyMetric? { cachedHrvDay }
+
+ private var restingHrDay: DailyMetric? { cachedRestingHrDay }
+
+ /// The skin-temp reading these cards LEAD with (#1844): today's row if it holds either number, else the
+ /// vitals carry, else the freshest prior row with either. Both numbers come off the SAME row, so an
+ /// absolute is never paired with another night's deviation. Twin of `TodayView.skinTempLeadReading`.
+ private var skinTempLeadReading: SkinTempDisplay.Reading? {
+ let row = [displayDay, vitalsDay, cachedSkinTempReadingDay]
+ .compactMap { $0 }
+ .first { $0.skinTempC != nil || $0.skinTempDevC != nil }
+ return SkinTempDisplay.leadReading(absC: row?.skinTempC, devC: row?.skinTempDevC,
+ prefer: SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute)
+ }
/// The Charge hero's resolved state (see `cachedChargeDisplay`), read O(1) from the cache.
private var chargeDisplay: ChargeDisplay { cachedChargeDisplay }
@@ -394,7 +425,9 @@ struct LiquidTodayView: View {
.liquidMediumHaptic(trigger: pullHaptic)
// hydrationSeq joins the id so logging a drink re-reads the card immediately, the same trigger set
// classic TodayView's reloadHydration() uses.
- .task(id: "\(repo.refreshSeq)-\(selectedDayOffset)-\(repo.hydrationSeq)-\(hydrationEnabled)") { await load() }
+ .task(id: "\(repo.refreshSeq)-\(selectedDayOffset)-\(repo.hydrationSeq)-\(hydrationEnabled)-\(dayCycleModeRaw)") {
+ await load()
+ }
.sheet(item: $guideSection) { section in
NavigationStack { ScoringGuideView(initialSection: section, onClose: { guideSection = nil }) }
}
@@ -410,6 +443,9 @@ struct LiquidTodayView: View {
hostedCardsRaw: $hostedCardsRaw
)
}
+ .sheet(isPresented: $showCoachLauncher) {
+ CoachLauncherSheet()
+ }
.sheet(isPresented: $showSettings) {
NavigationStack {
SettingsView()
@@ -453,7 +489,16 @@ struct LiquidTodayView: View {
private func handlePull(_ y: CGFloat) {
pullY = max(0, y)
guard !refreshing else { return }
- if pullY >= pullThreshold, !refreshArmed {
+ // #1748 twin: gate the ARM, not the release. `syncNow()`'s own gate checks connected + bonded, and
+ // `bonded` is set by the live-HR path for a 5/MG that has never completed a handshake — so the pull
+ // was accepted and then declined in silence. `historyReady` is the client's OWN precondition, so
+ // this cannot withhold a sync that would have run.
+ //
+ // On the ARM specifically: gating the RELEASE below would leave `refreshArmed` stuck true for the
+ // rest of the gesture, since that branch is the only thing that clears it — a worse failure than
+ // the silent one being fixed. Not arming also withholds the haptic, which is the honest signal
+ // that the gesture is unavailable rather than unresponsive.
+ if pullY >= pullThreshold, !refreshArmed, ble.state.historyReady {
refreshArmed = true
pullHaptic &+= 1
}
@@ -620,7 +665,7 @@ struct LiquidTodayView: View {
// one decimal on the compressed 0–21 axis to match the app-wide `effortDisplay` convention
// (12.6, not a rounded "13"); the 0–100 hero stays a whole number as before.
HeroScoreCell(label: String(localized: "Effort"),
- score: displayDay?.strain.map { UnitFormatter.effortValue($0, scale: effortScale) },
+ score: effortStrain(displayDay).map { UnitFormatter.effortValue($0, scale: effortScale) },
tint: StrandPalette.effortColor, animated: dataLoaded,
onGuide: { guideSection = .effort },
maxValue: effortScale == .whoop ? 21 : 100,
@@ -922,9 +967,10 @@ struct LiquidTodayView: View {
//
// frac stays nil deliberately. A signed deviation has no natural 0–100 fill, and a ring drawn
// from one would imply a magnitude the number does not carry.
- let skin = displayDay?.skinTempDevC ?? vitalsDay?.skinTempDevC
+ // #1844: lead with the night's measured ABSOLUTE when it has one; deviation nights unchanged.
+ let skin = skinTempLeadReading
cardLink(.metric("skin_temp"), title: card.title, sub: card.subtitle,
- value: TodayView.skinTempCardValue(skin, fahrenheit: temperatureUnit == .fahrenheit),
+ value: TodayView.skinTempCardValue(reading: skin, fahrenheit: temperatureUnit == .fahrenheit),
tint: StrandPalette.metricAmber, frac: nil)
case .calories:
// #616: show the resolved imported-first value and route to the matching detail source, like
@@ -951,6 +997,11 @@ struct LiquidTodayView: View {
// A tap-through to the full Coupled day screen. No value.
cardLink(.coupled, title: card.title, sub: card.subtitle,
value: "", tint: StrandPalette.chargeColor, frac: 0.6)
+ case .coach:
+ // #1862: a sheet rather than a push — the point of the card is to try Coach WITHOUT
+ // leaving Today.
+ cardAction(title: card.title, sub: card.subtitle, value: "",
+ tint: StrandPalette.accent, frac: 0.5) { showCoachLauncher = true }
}
}
@@ -959,7 +1010,26 @@ struct LiquidTodayView: View {
private func cardLink(_ route: TabRoute, title: String, sub: String,
value: String, tint: Color, frac: Double?) -> some View {
NavigationLink(value: route) {
- HStack(spacing: 12) {
+ cardLinkBody(title: title, sub: sub, value: value, tint: tint, frac: frac)
+ }
+ .buttonStyle(LiquidPressStyle())
+ }
+
+ /// The same row, running `action` instead of pushing (#1862). Coach is the one dashboard card that
+ /// opens a SHEET, so it cannot ride `NavigationLink`; sharing `cardLinkBody` keeps the two kinds of
+ /// row from drifting apart visually.
+ private func cardAction(title: String, sub: String, value: String, tint: Color, frac: Double?,
+ action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ cardLinkBody(title: title, sub: sub, value: value, tint: tint, frac: frac)
+ }
+ .buttonStyle(LiquidPressStyle())
+ }
+
+ @ViewBuilder
+ private func cardLinkBody(title: String, sub: String, value: String,
+ tint: Color, frac: Double?) -> some View {
+ HStack(spacing: 12) {
LiquidVessel(value: frac, tint: tint, animated: false).frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 1) {
Text(title.uppercased()).font(StrandFont.overlineScaled(11)).tracking(1.0)
@@ -973,9 +1043,7 @@ struct LiquidTodayView: View {
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
- .background(NoopPanelSurface(tint: tint, cornerRadius: 20, surfaceOpacity: cardOpacity))
- }
- .buttonStyle(LiquidPressStyle())
+ .background(NoopPanelSurface(tint: tint, cornerRadius: 20, surfaceOpacity: cardOpacity))
}
// MARK: - Synthesis (greeting + readiness pills + one-liner)
@@ -984,7 +1052,7 @@ struct LiquidTodayView: View {
/// card when today's Effort is ~0, so a calm day explains itself instead of a bare 0. Reuses classic's
/// String Catalog entry verbatim — one key serves both Today screens.
private var effortZeroNote: String? {
- guard EffortDisplay.showsZeroNote(strain: displayDay?.strain, isToday: selectedDayOffset == 0) else { return nil }
+ guard EffortDisplay.showsZeroNote(strain: effortStrain(displayDay), isToday: selectedDayOffset == 0) else { return nil }
return String(localized: "No cardio load yet. Effort builds once your heart rate climbs into your effort zone (around 50% of your heart-rate reserve). A calm day honestly reads near zero.")
}
@@ -1039,6 +1107,15 @@ struct LiquidTodayView: View {
Text(chargeDisplay.calibrationDetail ?? synthLine)
.font(StrandFont.body).foregroundStyle(StrandPalette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
+ // The reason the count is not moving, when nights are arriving empty. Sits under
+ // the progress rather than replacing it: the wearer needs both the number and why.
+ if let why = chargeDisplay.calibrationReason(
+ dayKeys: repo.days.map(\.day), nightlyHrv: repo.days.map(\.avgHrv),
+ today: Repository.logicalDayKey(Date())) {
+ Text(why).font(StrandFont.caption)
+ .foregroundStyle(StrandPalette.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
// #530 follow-up: the classic hero's "no cardio load yet" note (effortZeroNote),
// shown on a calm day so today's ~0 Effort explains itself instead of a bare 0.
if let note = effortZeroNote {
@@ -1069,9 +1146,10 @@ struct LiquidTodayView: View {
private var recoveryVitalsSection: some View {
// PER-FIELD, today-first carry: each vital reads today's own value, else falls back to the prior
- // day that recorded it (`vitalsDay`). Coalesce ONCE so the number and its fill fraction agree.
- let hrv = displayDay?.avgHrv ?? vitalsDay?.avgHrv
- let rhr = (displayDay?.restingHr ?? vitalsDay?.restingHr).map(Double.init)
+ // day that recorded THAT vital (#1842 — `vitalsDay` is the freshest row with ANY of the three, so it
+ // blanks one the row lacks). Coalesce ONCE so the number and its fill fraction agree.
+ let hrv = displayDay?.avgHrv ?? hrvDay?.avgHrv
+ let rhr = (displayDay?.restingHr ?? restingHrDay?.restingHr).map(Double.init)
let resp = displayDay?.respRateBpm ?? vitalsDay?.respRateBpm
return card {
VStack(alignment: .leading, spacing: 12) {
@@ -1083,22 +1161,48 @@ struct LiquidTodayView: View {
Text(line).font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
}
}
+ // #706/#684: the same trends the HRV / Resting HR / Respiratory dashboard cards push. These
+ // rows show the SAME three vitals and had no way through, so this card was the one place on
+ // Today where a metric was a dead end. Routes taken from `liquidCard`'s own cases so the two
+ // surfaces cannot send the same vital to different trends.
vitalRow(String(localized: "Heart-rate variability"), unitText(hrv, "ms"),
- StrandPalette.metricCyan, fracOver(hrv, 120))
+ StrandPalette.metricCyan, fracOver(hrv, 120), route: .metric("hrv"))
vitalRow(String(localized: "Resting heart rate"), unitText(rhr, "bpm"),
- StrandPalette.metricRose, fracOver(rhr, 100))
+ StrandPalette.metricRose, fracOver(rhr, 100), route: .metric("rhr"))
vitalRow(String(localized: "Breaths per minute"), unitText(resp, "rpm", decimals: 1),
- StrandPalette.accent, fracOver(resp, 24))
+ StrandPalette.accent, fracOver(resp, 24), route: .metric("resp_rate"))
}
}
}
- private func vitalRow(_ label: String, _ value: String, _ tint: Color, _ frac: Double?) -> some View {
+ /// A recovery-vital row, optionally pushing its own metric trend.
+ ///
+ /// `route: nil` renders exactly what shipped before - no link, no chevron - so a row that goes nowhere
+ /// never claims otherwise. `LiquidPressStyle` is not decoration: a bare `NavigationLink` applies the
+ /// default link chrome and would tint the whole row, which is the same reason `cardLink` carries it.
+ private func vitalRow(_ label: String, _ value: String, _ tint: Color, _ frac: Double?,
+ route: TabRoute? = nil) -> some View {
+ Group {
+ if let route {
+ NavigationLink(value: route) { vitalRowBody(label, value, tint, frac, linked: true) }
+ .buttonStyle(LiquidPressStyle())
+ } else {
+ vitalRowBody(label, value, tint, frac, linked: false)
+ }
+ }
+ }
+
+ private func vitalRowBody(_ label: String, _ value: String, _ tint: Color, _ frac: Double?,
+ linked: Bool) -> some View {
HStack(spacing: 12) {
LiquidVessel(value: frac, tint: tint, animated: false).frame(width: 26, height: 26)
Text(label).font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary)
Spacer()
Text(value).font(StrandFont.number(15)).foregroundStyle(StrandPalette.textPrimary)
+ if linked {
+ Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(StrandPalette.textTertiary)
+ }
}
}
@@ -1133,8 +1237,8 @@ struct LiquidTodayView: View {
// HRV / Rest HR (+ Blood Oxygen / Respiratory) tiles share the recovery vitals' per-field
// today-first carry so they don't blank at the rollover while Recovery/Strain/Rest stay strictly
// today's own (they are scored surfaces).
- let hrv = displayDay?.avgHrv ?? vitalsDay?.avgHrv
- let rhr = (displayDay?.restingHr ?? vitalsDay?.restingHr).map(Double.init)
+ let hrv = displayDay?.avgHrv ?? hrvDay?.avgHrv
+ let rhr = (displayDay?.restingHr ?? restingHrDay?.restingHr).map(Double.init)
return VStack(spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
sectionHead("KEY METRICS", trailing: trendWindowLabel)
@@ -1183,7 +1287,12 @@ struct LiquidTodayView: View {
// stays raw, matching the Effort hero, which correctly does not carry.
ktile(String(localized: "Recovery"), icon: keyMetricIcon(metric), intText(chargeDisplay.pct), "%", StrandPalette.chargeColor, frac(chargeDisplay.pct), key: "recovery")
case .effort:
- ktile(String(localized: "Strain"), icon: keyMetricIcon(metric), intText(displayDay?.strain), "%", StrandPalette.effortColor, frac(displayDay?.strain), key: "strain")
+ // #492: Effort is a load index (0–100 NOOP / 0–21 WHOOP), NOT a percentage, and the unit was
+ // wrong on either axis. Fixed on Android and in `TodayView` at the time; THIS view kept the old
+ // form, so the tile also ignored the scale toggle — the hero ring above it read ~8 on the WHOOP
+ // axis while this read 38. `effortText` is the same shared formatter the ring and the workout
+ // rows use, so all three now agree by construction.
+ ktile(String(localized: "Strain"), icon: keyMetricIcon(metric), effortText(effortStrain(displayDay)), "", StrandPalette.effortColor, frac(effortStrain(displayDay)), key: "strain")
case .rest:
ktile(String(localized: "Rest"), icon: keyMetricIcon(metric), intText(restScore), "%", StrandPalette.restColor, frac(restScore), key: "sleep_performance")
case .hrv:
@@ -1218,6 +1327,19 @@ struct LiquidTodayView: View {
// detail source, so the number, its sparkline and the chart it opens all agree.
ktile(String(localized: "Calories"), icon: keyMetricIcon(metric), intText(caloriesCount), "kcal", StrandPalette.metricAmber,
fracOver(caloriesCount, 800), key: "energy_kcal", detailMetric: caloriesDetailMetric)
+ case .skinTemp:
+ // Added 2026-08-24 (queue 11c follow-up): first Key Metrics appearance for Skin Temp — was
+ // already a "Your Cards" tile (`DashboardCard.skinTemp`), never a Key Metrics one. Same
+ // 2-level carry the Blood Oxygen case just above uses (displayDay → the cached vitals carry),
+ // and the SAME `SkinTempDisplay` formatter every other skin-temp surface uses so a deviation
+ // reads "+0.1 Δ°C" here exactly as it does on "Your Cards"/the Deep Timeline, never the plain
+ // `%+.1f°` that read a fabricated absolute value for a signed deviation (#622).
+ // #1844: same lead-with-the-absolute resolution as "Your Cards" above, so the two agree.
+ let skinText = TodayView.skinTempCardValue(reading: skinTempLeadReading,
+ fahrenheit: temperatureUnit == .fahrenheit)
+ // The card's own unit is deliberately empty — the value carries "°C"/"Δ°F" itself, same as
+ // the classic TodayView Skin Temp card.
+ ktile(String(localized: "Skin Temp"), icon: keyMetricIcon(metric), skinText, "", StrandPalette.metricAmber, nil, key: "skin_temp")
}
}
@@ -1233,12 +1355,13 @@ struct LiquidTodayView: View {
case .steps: return "figure.walk"
case .weight: return "scalemass.fill"
case .calories: return "flame.fill"
+ case .skinTemp: return "thermometer.medium"
}
}
private func ktile(_ label: String, icon: String, _ value: String, _ unit: String, _ tint: Color, _ frac: Double?,
key: String? = nil, detailMetric: MetricDescriptor? = nil, caption: String? = nil) -> some View {
- let displayValue = unit.isEmpty ? value : (unit == "%" ? value + unit : value + " " + unit)
+ let displayValue = Self.tileDisplayValue(value, unit: unit)
let tile = VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: icon)
@@ -1410,6 +1533,9 @@ struct LiquidTodayView: View {
let tkey = cachedDisplayDay?.day ?? selectedDayKey
cachedVitalsDay = (selectedDayOffset == 0) ? Repository.lastVitalsDay(days: repo.days, todayKey: tkey) : nil
cachedRespDay = (selectedDayOffset == 0) ? Repository.lastRespDay(days: repo.days, todayKey: tkey) : nil
+ cachedHrvDay = (selectedDayOffset == 0) ? Repository.lastHrvDay(days: repo.days, todayKey: tkey) : nil
+ cachedRestingHrDay = (selectedDayOffset == 0) ? Repository.lastRestingHrDay(days: repo.days, todayKey: tkey) : nil
+ cachedSkinTempReadingDay = (selectedDayOffset == 0) ? Repository.lastSkinTempReadingDay(days: repo.days, todayKey: tkey) : nil
// Charge carry (#543) + the honest label, resolved here for the same reason as the two above: the
// selector below scans repo.days. Calibration nights come from the SAME `RecoveryScorer` helper the
// classic Today reads, so the two screens agree on when a wearer is genuinely mid-calibration
@@ -1433,11 +1559,39 @@ struct LiquidTodayView: View {
let cal = Calendar.current
let dayStart = cal.startOfDay(for: selectedLogicalDay)
- let from = Int(dayStart.timeIntervalSince1970)
+ let calendarFrom = Int(dayStart.timeIntervalSince1970)
// today → midnight..now; a past day → its full 24h (a missing morning reads as empty space).
- let to: Int = selectedDayOffset == 0
+ let calendarTo: Int = selectedDayOffset == 0
? Int(Date().timeIntervalSince1970)
: Int((cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart).timeIntervalSince1970)
+ let nextDayKey = Repository.localDayKey(cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart)
+ let cycleMarkers = dayCycleMode == .sleepOnset
+ ? await repo.exploreSeries(key: DayCycleIntelligenceIntegration.onsetKey, source: "my-whoop") : []
+ let from = cycleMarkers.last(where: { $0.day == selectedDayKey }).map { Int($0.value) } ?? calendarFrom
+ let toExclusive = cycleMarkers.last(where: { $0.day == nextDayKey }).map { Int($0.value) } ?? calendarTo
+ let to = max(from, toExclusive - 1)
+ // #1001: in-progress Effort for TODAY, over the SAME window resolved just above (the day-cycle
+ // onset when that mode is on, else calendar midnight → now) with the identical params the daily
+ // pass uses, so the live number matches what the engine will eventually persist. Below
+ // `StrainScorer.minReadings` the scorer returns nil and the read-outs fall back to the stored row
+ // — never a fabricated value. A navigated past day clears it.
+ let liveStrainLocal: Double?
+ if selectedDayOffset == 0 {
+ // An EXPLICIT limit, not the 8000 default: that default is chart-sized, and this read is
+ // whole-window. `hrSamples` is `ORDER BY ts ASC LIMIT`, so truncation drops the NEWEST rows —
+ // at the ~18k HR rows a real day banks, the default covered roughly the first ten hours and the
+ // live score silently stopped climbing after that. It failed safe (`effectiveEffort` takes the
+ // max, so the stored row simply won) which is why it went unnoticed. 200_000 is what every
+ // other whole-window HR consumer already passes.
+ let todayHr = await repo.hrSamples(from: from, to: to, limit: 200_000)
+ let maxHR = profile.age > 0 ? StrainScorer.tanakaHRmax(age: Double(profile.age)) : nil
+ let restHR = day?.restingHr.map(Double.init) ?? StrainScorer.defaultRestingHR
+ liveStrainLocal = StrainScorer.strain(todayHr, maxHR: maxHR, restingHR: restHR,
+ method: PuffinExperiment.effortMethod, sex: profile.sex)
+ } else {
+ liveStrainLocal = nil
+ }
+ liveTodayStrain = liveStrainLocal
async let restA = repo.exploreSeries(key: "sleep_performance", source: "my-whoop")
async let stressA = repo.series(key: "stress", source: "my-whoop")
@@ -1511,6 +1665,9 @@ struct LiquidTodayView: View {
"rhr": sparkRows.compactMap { r in r.restingHr.map { (r.day, Double($0)) } },
"spo2": sparkRows.compactMap { r in r.spo2Pct.map { (r.day, $0) } },
"spo2_candidate": spo2CandSeries.filter { $0.day >= sparkCutoff && $0.day <= selectedDayKey },
+ // Added 2026-08-24 (queue 11c follow-up) for the new Skin Temp Key Metrics tile — already
+ // loaded on `sparkRows` (`daysSnapshot`), same as every other DailyMetric-column tile above.
+ "skin_temp": sparkRows.compactMap { r in r.skinTempDevC.map { (r.day, $0) } },
"resp_rate": sparkRows.compactMap { r in r.respRateBpm.map { (r.day, $0) } },
"steps": sparkRows.compactMap { r in r.steps.map { (r.day, Double($0)) } },
// #616: the Calories tile drew no trend line — this dict had no matching entry, so windowedSpark
@@ -1541,7 +1698,7 @@ struct LiquidTodayView: View {
// HR estimate for the day, so the tile/card/detail agree (imported-first, mirrors steps).
importedActiveKcalDay = (await appleA).filter { $0.day == selectedDayKey }.compactMap { $0.activeKcal }.max()
hrValues = (await hrA).map { $0.bpm }
- workouts = await wkA
+ workouts = (await wkA).filter { $0.startTs >= from && $0.startTs < to }
let (chargeSource, effortSource, restSource) = await (chargeSourceA, effortSourceA, restSourceA)
let sourceResolutions = [
@@ -1575,7 +1732,7 @@ struct LiquidTodayView: View {
if HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(where: { $0.origin == sleepOrigin }) {
let hostedSessions = await repo.allSleepSessions()
let hostedHabitual = await repo.habitualMidsleepSec()
- let hostedMotion = await repo.sessionMotions(starts: hostedSessions.map { $0.startTs })
+ let hostedMotion = await repo.sessionMotions(sessions: hostedSessions)
hostedSleepModel = SleepModel.build(SleepModelInputs(
days: repo.days,
sleeps: repo.sleeps,
@@ -1697,10 +1854,26 @@ struct LiquidTodayView: View {
private func frac(_ v: Double?) -> Double? { v.map { max(0, min(1, $0 / 100)) } }
private func fracOver(_ v: Double?, _ over: Double) -> Double? { v.map { max(0, min(1, $0 / over)) } }
- private func intText(_ v: Double?) -> String { v.map { String(Int($0.rounded())) } ?? "–" }
+
+ /// What a tile shows when the metric has no value. One constant rather than a dash repeated at each
+ /// site, because the unit-suppression below has to recognise it.
+ static let noValueDash = "–"
+
+ /// Join a formatted tile value with its unit, dropping the unit when there is no value.
+ ///
+ /// #492 and the `–%` it left behind: a missing metric rendered its placeholder AND its unit, so an
+ /// empty Strain tile read `–%`, which parses as "minus percent" rather than "no data". Android has
+ /// had the guard all along (`unit = if (restScore != null) "%" else ""`, and `withUnit`'s NO_DATA
+ /// check); this is the iOS twin of it. `%` binds tight, every other unit takes a space.
+ static func tileDisplayValue(_ value: String, unit: String) -> String {
+ guard !unit.isEmpty, value != noValueDash else { return value }
+ return unit == "%" ? value + unit : value + " " + unit
+ }
+
+ private func intText(_ v: Double?) -> String { v.map { String(Int($0.rounded())) } ?? Self.noValueDash }
private func unitText(_ v: Double?, _ unit: String, decimals: Int = 0) -> String {
- guard let v else { return "–" }
+ guard let v else { return Self.noValueDash }
let n = decimals > 0 ? String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, v) : String(Int(v.rounded()))
return unit.isEmpty ? n : "\(n) \(unit)"
}
@@ -1726,7 +1899,12 @@ struct LiquidTodayView: View {
// even once it had a value to show (#1627).
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(system: unitSystem, override: distanceSystemRaw)
+ }
@AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = ""
+ @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846
private var temperatureUnit: TemperatureUnit {
UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw)
}
@@ -1736,8 +1914,17 @@ struct LiquidTodayView: View {
@AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue
private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) }
+ /// The Effort this view should show: the live in-progress score when it beats the stored row, else the
+ /// row (#1001). `StrainScorer.effectiveEffort` holds the never-drop floor and the live/stored
+ /// preference and is shared with the Kotlin twin, so the two platforms cannot resolve Effort
+ /// differently. `d` for today is always today's row or nil, never a prior day, so the floor cannot
+ /// resurrect a stale day — it only stops a read-out dropping below what today has already earned.
+ private func effortStrain(_ d: DailyMetric?) -> Double? {
+ StrainScorer.effectiveEffort(live: selectedDayOffset == 0 ? liveTodayStrain : nil, stored: d?.strain)
+ }
+
private func effortText(_ s: Double?) -> String {
- guard let s else { return "–" }
+ guard let s else { return Self.noValueDash }
// Route through the shared formatter instead of hardcoding *21: a default (0–100) user was shown the
// WHOOP-scaled number here while the hero + Workouts table showed 0–100, two numbers for one workout.
return UnitFormatter.effortDisplay(s, scale: effortScale)
@@ -1747,7 +1934,9 @@ struct LiquidTodayView: View {
var parts: [String] = []
let secs = w.durationS ?? Double(max(w.endTs - w.startTs, 0))
parts.append("\(Int(secs / 60)) min")
- if let dm = w.distanceM, dm > 0 { parts.append(String(format: "%.1f km", locale: AppLanguage.activeLocale, dm / 1000)) }
+ if let dm = w.distanceM, dm > 0 {
+ parts.append(UnitFormatter.distanceFromMeters(dm, system: distanceUnitSystem))
+ }
if let k = w.energyKcal { parts.append("\(Int(k.rounded())) kcal") }
return parts.joined(separator: " · ")
}
@@ -1767,11 +1956,15 @@ struct LiquidTodayView: View {
/// "Latest sleep · " (#779) instead of a false "Last night". When every shown vital is today's
/// own (or there's nothing to carry), it returns nil — the card must not claim "Last night" at all.
private var vitalsProvenanceLine: String? {
- guard let carried = vitalsDay else { return nil }
- let carriedHrv = displayDay?.avgHrv == nil && carried.avgHrv != nil
- let carriedRhr = displayDay?.restingHr == nil && carried.restingHr != nil
- let carriedResp = displayDay?.respRateBpm == nil && carried.respRateBpm != nil
- guard carriedHrv || carriedRhr || carriedResp else { return nil }
+ // Each vital can carry from a DIFFERENT row (#1842), so the one card-level footnote stamps the
+ // OLDEST row any SHOWN carried vital came from — erring old is the only safe direction for a caption
+ // whose job is to stop a stale read passing as today's, and it keeps the "Latest sleep" relabel
+ // (#779) firing on the value that actually is weeks old. A row counts only if it SUPPLIED the value.
+ let fromHrv: DailyMetric? = (displayDay?.avgHrv == nil && hrvDay?.avgHrv != nil) ? hrvDay : nil
+ let fromRhr: DailyMetric? = (displayDay?.restingHr == nil && restingHrDay?.restingHr != nil) ? restingHrDay : nil
+ let fromResp: DailyMetric? = (displayDay?.respRateBpm == nil && vitalsDay?.respRateBpm != nil) ? vitalsDay : nil
+ let sources: [DailyMetric] = [fromHrv, fromRhr, fromResp].compactMap { $0 }
+ guard let carried = sources.min(by: { $0.day < $1.day }) else { return nil }
return TodayView.carriedCaption(priorDayKey: carried.day,
todayKey: displayDay?.day ?? selectedDayKey)
}
@@ -2210,9 +2403,9 @@ extension LiquidTodayView {
/// The Effort hero's "no cardio load yet" honest note (#530 follow-up — Liquid parity with classic
/// `TodayView.effortZeroNote`). Pure + static so the gate is testable with no view: the note shows
/// ONLY for today when a strain value exists and is ~0 — a genuinely calm day reads near zero, while a
- /// no-data day shows its own ring overlay and a past day is never annotated. Liquid reads
- /// `displayDay?.strain` directly (it has no live-strain accumulator like classic's `liveTodayStrain`),
- /// which is exactly the value its Effort hero draws.
+ /// no-data day shows its own ring overlay and a past day is never annotated. The caller passes
+ /// `effortStrain(displayDay)` — the SAME resolved value the Effort hero draws (#1001) — so the note
+ /// and the hero can never disagree about whether today is at zero.
enum EffortDisplay {
static func showsZeroNote(strain: Double?, isToday: Bool) -> Bool {
guard isToday, let s = strain else { return false }
@@ -2293,6 +2486,15 @@ extension LiquidTodayView {
return String(localized: "Learning your baseline, \(nights) of \(Baselines.minNightsSeed) nights.")
}
+ /// The reason half, when nights are arriving but most carry no HRV (see `TodayView`'s twin). Nil
+ /// when every observed night counted, so a healthy calibration says nothing extra.
+ func calibrationReason(dayKeys: [String], nightlyHrv: [Double?], today: String) -> String? {
+ guard case .calibrating = self else { return nil }
+ let cov = Baselines.recentHrvCoverage(dayKeys: dayKeys, nightlyHrv: nightlyHrv, today: today)
+ guard cov.missing > 0, cov.observed > 0 else { return nil }
+ return String(localized: "\(cov.missing) of the last \(cov.observed) nights recorded no HRV. Check the strap is worn overnight and syncing.")
+ }
+
static func resolve(todayRecovery: Double?, priorScored: DailyMetric?,
calibrationNights: Int?, todayKey: String) -> ChargeDisplay {
if let pct = todayRecovery { return .scored(pct: pct) }
diff --git a/Strand/Onboarding/OnboardingWizard.swift b/Strand/Onboarding/OnboardingWizard.swift
index b7db8cb72f..e91d24f4af 100644
--- a/Strand/Onboarding/OnboardingWizard.swift
+++ b/Strand/Onboarding/OnboardingWizard.swift
@@ -727,10 +727,17 @@ private struct BondedStep: View {
private struct ProfileStep: View {
@EnvironmentObject private var profile: ProfileStore
- // Imperial/Metric display preference (D#103). The stored profile is always SI; the steppers keep
- // operating in SI (0.5 kg / 1 cm) and only the DISPLAYED value re-labels to lb / ft-in.
+ // The stored profile is always SI. Body measurements and exercise distance can follow the regional
+ // conventions independently; an unset distance choice follows the body choice for compatibility.
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(system: unitSystem, override: distanceSystemRaw)
+ }
+ private var distanceSystemBinding: Binding {
+ Binding(get: { distanceUnitSystem.rawValue }, set: { distanceSystemRaw = $0 })
+ }
private let sexes: [(String, String)] = [
("male", String(localized: "Male")), ("female", String(localized: "Female")),
@@ -767,14 +774,11 @@ private struct ProfileStep: View {
Divider().overlay(StrandPalette.hairline)
- // Units control (#781). Without this, onboarding read `unitSystemRaw` for the
- // Weight/Height display but had NO way to set it, so US users were locked to
- // kg/cm until they later found Settings → Units. Mirror the Sex picker idiom; the
- // stored profile stays SI either way, only the displayed labels re-format (lb / ft-in
- // via UnitFormatter). Same key (`units.system`) the Settings → Units card writes.
+ // Keep the two choices explicit here: "Metric/Imperial" alone cannot describe
+ // common mixed conventions such as Canadian pounds with kilometres.
VStack(alignment: .leading, spacing: 8) {
- Text("Units").strandOverline()
- Picker("Units", selection: $unitSystemRaw) {
+ Text("Body measurements").strandOverline()
+ Picker("Body measurements", selection: $unitSystemRaw) {
Text("Metric").tag(UnitSystem.metric.rawValue)
Text("Imperial").tag(UnitSystem.imperial.rawValue)
}
@@ -784,6 +788,18 @@ private struct ProfileStep: View {
Divider().overlay(StrandPalette.hairline)
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Exercise distance & pace").strandOverline()
+ Picker("Exercise distance & pace", selection: distanceSystemBinding) {
+ Text("Kilometres").tag(UnitSystem.metric.rawValue)
+ Text("Miles").tag(UnitSystem.imperial.rawValue)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ }
+
+ Divider().overlay(StrandPalette.hairline)
+
// Steppers, not sliders — matches the Age row above and the macOS Settings
// profile editor (same ranges/steps), so every numeric profile field is
// consistent across onboarding and Settings on both platforms.
diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings
index ac5eaae10c..0844b4a27e 100644
--- a/Strand/Resources/Localizable.xcstrings
+++ b/Strand/Resources/Localizable.xcstrings
@@ -1,14 +1,80 @@
{
"sourceLanguage": "en",
"strings": {
+ "Heart rate, no reading": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz, keine Messung" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate, no reading" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca, sin lectura" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque, aucune mesure" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca, nessuna lettura" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno, brak odczytu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca, sem leitura" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс, нет данных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率,无读数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率,無讀數" } }
+ } },
+ "Heart rate %lld bpm": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz %lld bpm" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate %lld bpm" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca %lld ppm" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque %lld bpm" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca %lld bpm" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno %lld bpm" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca %lld bpm" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс %lld уд/мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm" } }
+ } },
+ "Heart rate %lld bpm, minimum %lld, maximum %lld": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz %lld bpm, Minimum %lld, Maximum %lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate %lld bpm, minimum %lld, maximum %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca %lld ppm, mínimo %lld, máximo %lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque %lld bpm, minimum %lld, maximum %lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca %lld bpm, minimo %lld, massimo %lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno %lld bpm, minimum %lld, maksimum %lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca %lld bpm, mínimo %lld, máximo %lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс %lld уд/мин, минимум %lld, максимум %lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm,最低 %lld,最高 %lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率 %lld bpm,最低 %lld,最高 %lld" } }
+ } },
+ "Min %lld • Max %lld": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Min. %lld • Max. %lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Min %lld • Max %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Mín %lld • Máx %lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Min. %lld • Max. %lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Min. %lld • Max. %lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Min %lld • Maks %lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mín %lld • Máx %lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Мин %lld • Макс %lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最低 %lld • 最高 %lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最低 %lld • 最高 %lld" } }
+ } },
+ "Updated %@": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Aktualisiert %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Updated %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Actualizado %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Mis à jour %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Aggiornato %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zaktualizowano %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Atualizado %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Обновлено %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "更新于 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "更新於 %@" } }
+ } },
+ "Body measurement units": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Einheiten für Körpermaße" } }, "es": { "stringUnit": { "state": "translated", "value": "Unidades de medidas corporales" } }, "fr": { "stringUnit": { "state": "translated", "value": "Unités des mensurations" } }, "it": { "stringUnit": { "state": "translated", "value": "Unità delle misure corporee" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jednostki pomiarów ciała" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Unidades das medidas corporais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Единицы параметров тела" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "身体测量单位" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "身體測量單位" } }
+ } },
+ "Body measurements": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Körpermaße" } }, "es": { "stringUnit": { "state": "translated", "value": "Medidas corporales" } }, "fr": { "stringUnit": { "state": "translated", "value": "Mensurations" } }, "it": { "stringUnit": { "state": "translated", "value": "Misure corporee" } }, "pl": { "stringUnit": { "state": "translated", "value": "Pomiary ciała" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Medidas corporais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Параметры тела" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "身体测量" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "身體測量" } }
+ } },
+ "Choose body measurements and exercise distance separately. Your data is always stored the same way; these settings only change its display.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Wähle Einheiten für Körpermaße und Trainingsdistanz separat. Deine Daten werden immer gleich gespeichert; diese Einstellungen ändern nur die Anzeige." } }, "es": { "stringUnit": { "state": "translated", "value": "Elige por separado las unidades de las medidas corporales y de la distancia de ejercicio. Los datos siempre se almacenan igual; estos ajustes solo cambian su visualización." } }, "fr": { "stringUnit": { "state": "translated", "value": "Choisissez séparément les unités des mensurations et de la distance d’exercice. Les données sont toujours stockées de la même façon ; ces réglages ne changent que leur affichage." } }, "it": { "stringUnit": { "state": "translated", "value": "Scegli separatamente le unità per le misure corporee e la distanza di allenamento. I dati vengono sempre memorizzati allo stesso modo; queste impostazioni cambiano solo la visualizzazione." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wybierz osobno jednostki pomiarów ciała i dystansu treningu. Dane są zawsze przechowywane tak samo; te ustawienia zmieniają tylko sposób wyświetlania." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Escolha separadamente as unidades para medidas corporais e distância de exercício. Os dados são sempre armazenados da mesma forma; estas definições alteram apenas a apresentação." } }, "ru": { "stringUnit": { "state": "translated", "value": "Выберите единицы для параметров тела и дистанции тренировки отдельно. Данные всегда хранятся одинаково; эти настройки меняют только отображение." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "分别选择身体测量和运动距离的单位。数据的存储方式始终不变;这些设置只会改变显示方式。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "分別選擇身體測量和運動距離的單位。資料的儲存方式始終不變;這些設定只會改變顯示方式。" } }
+ } },
+ "Distance must be 0–621 mi.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Die Distanz muss zwischen 0 und 621 mi liegen." } }, "es": { "stringUnit": { "state": "translated", "value": "La distancia debe ser de 0 a 621 mi." } }, "fr": { "stringUnit": { "state": "translated", "value": "La distance doit être comprise entre 0 et 621 mi." } }, "it": { "stringUnit": { "state": "translated", "value": "La distanza deve essere compresa tra 0 e 621 mi." } }, "pl": { "stringUnit": { "state": "translated", "value": "Dystans musi wynosić od 0 do 621 mi." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A distância deve ser entre 0 e 621 mi." } }, "ru": { "stringUnit": { "state": "translated", "value": "Дистанция должна быть от 0 до 621 мили." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "距离必须在 0–621 英里之间。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "距離必須在 0–621 英里之間。" } }
+ } },
+ "Exercise distance & pace": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Trainingsdistanz & Tempo" } }, "es": { "stringUnit": { "state": "translated", "value": "Distancia y ritmo del ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Distance et allure d’exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Distanza e passo dell’allenamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dystans i tempo treningu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Distância e ritmo do exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Дистанция и темп тренировки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "运动距离和配速" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "運動距離和配速" } }
+ } },
+ "Exercise distance and pace units": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Einheiten für Trainingsdistanz und Tempo" } }, "es": { "stringUnit": { "state": "translated", "value": "Unidades de distancia y ritmo del ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Unités de distance et d’allure d’exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Unità di distanza e passo dell’allenamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jednostki dystansu i tempa treningu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Unidades de distância e ritmo do exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Единицы дистанции и темпа тренировки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "运动距离和配速单位" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "運動距離和配速單位" } }
+ } },
+ "Follow body": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Wie Körpermaße" } }, "es": { "stringUnit": { "state": "translated", "value": "Según medidas corporales" } }, "fr": { "stringUnit": { "state": "translated", "value": "Selon les mensurations" } }, "it": { "stringUnit": { "state": "translated", "value": "Come misure corporee" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak pomiary ciała" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Como medidas corporais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Как для параметров тела" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "跟随身体测量" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "跟隨身體測量" } }
+ } },
+ "Kilometres": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Kilometer" } }, "es": { "stringUnit": { "state": "translated", "value": "Kilómetros" } }, "fr": { "stringUnit": { "state": "translated", "value": "Kilomètres" } }, "it": { "stringUnit": { "state": "translated", "value": "Chilometri" } }, "pl": { "stringUnit": { "state": "translated", "value": "Kilometry" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quilómetros" } }, "ru": { "stringUnit": { "state": "translated", "value": "Километры" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "公里" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "公里" } }
+ } },
+ "Miles": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Meilen" } }, "es": { "stringUnit": { "state": "translated", "value": "Millas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Miles" } }, "it": { "stringUnit": { "state": "translated", "value": "Miglia" } }, "pl": { "stringUnit": { "state": "translated", "value": "Mile" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Milhas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Мили" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "英里" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "英里" } }
+ } },
+ "Calculating last night's sleep…": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Der Schlaf der letzten Nacht wird berechnet…" } }, "en": { "stringUnit": { "state": "translated", "value": "Calculating last night's sleep…" } }, "es": { "stringUnit": { "state": "translated", "value": "Calculando el sueño de anoche…" } }, "fr": { "stringUnit": { "state": "translated", "value": "Calcul du sommeil de la nuit dernière…" } }, "it": { "stringUnit": { "state": "translated", "value": "Calcolo del sonno della scorsa notte…" } }, "pl": { "stringUnit": { "state": "translated", "value": "Obliczanie snu z ostatniej nocy…" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A calcular o sono da noite passada…" } }, "ru": { "stringUnit": { "state": "translated", "value": "Рассчитываю сон за прошлую ночь…" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在计算昨夜睡眠…" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在計算昨夜睡眠…" } }
+ } },
+ "Your strap history is in. NOOP is detecting and staging the night now.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Dein Strap-Verlauf ist da. NOOP erkennt und analysiert die Nacht jetzt." } }, "en": { "stringUnit": { "state": "translated", "value": "Your strap history is in. NOOP is detecting and staging the night now." } }, "es": { "stringUnit": { "state": "translated", "value": "El historial de tu pulsera ya está disponible. NOOP está detectando y analizando la noche." } }, "fr": { "stringUnit": { "state": "translated", "value": "L’historique de votre bracelet est arrivé. NOOP détecte et analyse maintenant la nuit." } }, "it": { "stringUnit": { "state": "translated", "value": "La cronologia della fascia è arrivata. NOOP sta rilevando e analizzando la notte." } }, "pl": { "stringUnit": { "state": "translated", "value": "Historia z opaski jest już dostępna. NOOP wykrywa i analizuje teraz noc." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O histórico da bracelete chegou. A NOOP está agora a detetar e analisar a noite." } }, "ru": { "stringUnit": { "state": "translated", "value": "История с браслета загружена. NOOP сейчас определяет и анализирует ночной сон." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "手环历史数据已到达。NOOP 正在识别并分析这一夜的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "手環歷史資料已到達。NOOP 正在識別並分析這一夜的睡眠。" } }
+ } },
+ "Last night's sleep hasn't synced": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Der Schlaf der letzten Nacht wurde nicht synchronisiert" } }, "en": { "stringUnit": { "state": "translated", "value": "Last night's sleep hasn't synced" } }, "es": { "stringUnit": { "state": "translated", "value": "El sueño de anoche no se ha sincronizado" } }, "fr": { "stringUnit": { "state": "translated", "value": "Le sommeil de la nuit dernière n’a pas été synchronisé" } }, "it": { "stringUnit": { "state": "translated", "value": "Il sonno della scorsa notte non è stato sincronizzato" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sen z ostatniej nocy nie został zsynchronizowany" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O sono da noite passada não foi sincronizado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сон за прошлую ночь не синхронизирован" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "昨夜睡眠尚未同步" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "昨夜睡眠尚未同步" } }
+ } },
+ "The history sync stopped before it finished. Keep the strap nearby and try Sync again.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Die Verlaufssynchronisierung wurde vorzeitig beendet. Halte den Strap in der Nähe und versuche die Synchronisierung erneut." } }, "en": { "stringUnit": { "state": "translated", "value": "The history sync stopped before it finished. Keep the strap nearby and try Sync again." } }, "es": { "stringUnit": { "state": "translated", "value": "La sincronización del historial se detuvo antes de terminar. Mantén la pulsera cerca e inténtalo de nuevo." } }, "fr": { "stringUnit": { "state": "translated", "value": "La synchronisation de l’historique s’est arrêtée avant la fin. Gardez le bracelet à proximité et réessayez." } }, "it": { "stringUnit": { "state": "translated", "value": "La sincronizzazione della cronologia si è interrotta prima di terminare. Tieni la fascia vicina e riprova." } }, "pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja historii zatrzymała się przed ukończeniem. Trzymaj opaskę w pobliżu i spróbuj ponownie." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A sincronização do histórico parou antes de terminar. Mantém a bracelete por perto e tenta novamente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Синхронизация истории остановилась до завершения. Держите браслет рядом и повторите попытку." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "历史同步未完成就停止了。请将手环放在附近并再次尝试同步。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "歷史同步未完成就停止了。請將手環放在附近並再次嘗試同步。" } }
+ } },
+ "Waiting for last night's sleep": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Warten auf den Schlaf der letzten Nacht" } }, "en": { "stringUnit": { "state": "translated", "value": "Waiting for last night's sleep" } }, "es": { "stringUnit": { "state": "translated", "value": "Esperando el sueño de anoche" } }, "fr": { "stringUnit": { "state": "translated", "value": "En attente du sommeil de la nuit dernière" } }, "it": { "stringUnit": { "state": "translated", "value": "In attesa del sonno della scorsa notte" } }, "pl": { "stringUnit": { "state": "translated", "value": "Oczekiwanie na sen z ostatniej nocy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "À espera do sono da noite passada" } }, "ru": { "stringUnit": { "state": "translated", "value": "Ожидание сна за прошлую ночь" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在等待昨夜睡眠" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在等待昨夜睡眠" } }
+ } },
+ "Connect the strap and sync its history. NOOP will calculate the night when the overnight data arrives.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Verbinde den Strap und synchronisiere seinen Verlauf. NOOP berechnet die Nacht, sobald die Nachtdaten eintreffen." } }, "en": { "stringUnit": { "state": "translated", "value": "Connect the strap and sync its history. NOOP will calculate the night when the overnight data arrives." } }, "es": { "stringUnit": { "state": "translated", "value": "Conecta la pulsera y sincroniza su historial. NOOP calculará la noche cuando lleguen los datos nocturnos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Connectez le bracelet et synchronisez son historique. NOOP calculera la nuit lorsque les données nocturnes arriveront." } }, "it": { "stringUnit": { "state": "translated", "value": "Collega la fascia e sincronizza la cronologia. NOOP calcolerà la notte quando arriveranno i dati notturni." } }, "pl": { "stringUnit": { "state": "translated", "value": "Połącz opaskę i zsynchronizuj jej historię. NOOP obliczy noc po otrzymaniu danych nocnych." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Liga a bracelete e sincroniza o histórico. A NOOP calculará a noite quando os dados noturnos chegarem." } }, "ru": { "stringUnit": { "state": "translated", "value": "Подключите браслет и синхронизируйте историю. NOOP рассчитает ночь после получения ночных данных." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "连接手环并同步历史。收到夜间数据后,NOOP 会计算这一夜的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "連接手環並同步歷史。收到夜間資料後,NOOP 會計算這一夜的睡眠。" } }
+ } },
+ "Last night's sleep wasn't detected": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Der Schlaf der letzten Nacht wurde nicht erkannt" } }, "en": { "stringUnit": { "state": "translated", "value": "Last night's sleep wasn't detected" } }, "es": { "stringUnit": { "state": "translated", "value": "No se detectó el sueño de anoche" } }, "fr": { "stringUnit": { "state": "translated", "value": "Le sommeil de la nuit dernière n’a pas été détecté" } }, "it": { "stringUnit": { "state": "translated", "value": "Il sonno della scorsa notte non è stato rilevato" } }, "pl": { "stringUnit": { "state": "translated", "value": "Nie wykryto snu z ostatniej nocy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O sono da noite passada não foi detetado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сон за прошлую ночь не обнаружен" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未检测到昨夜睡眠" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未偵測到昨夜睡眠" } }
+ } },
+ "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Die Synchronisierung ist abgeschlossen, aber NOOP konnte kein Schlafzeitfenster sicher erkennen. Lass den Strap verbunden und synchronisiere erneut; die ältere Nacht unten ist weiterhin dein zuletzt erkannter Schlaf." } }, "en": { "stringUnit": { "state": "translated", "value": "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep." } }, "es": { "stringUnit": { "state": "translated", "value": "La sincronización terminó, pero NOOP no pudo identificar con confianza un periodo de sueño. Mantén la pulsera conectada y vuelve a sincronizar; la noche anterior que aparece abajo sigue siendo tu último sueño detectado." } }, "fr": { "stringUnit": { "state": "translated", "value": "La synchronisation est terminée, mais NOOP n’a pas pu identifier avec certitude une période de sommeil. Gardez le bracelet connecté et relancez la synchronisation ; la nuit plus ancienne ci-dessous reste votre dernier sommeil détecté." } }, "it": { "stringUnit": { "state": "translated", "value": "La sincronizzazione è terminata, ma NOOP non ha identificato con certezza un intervallo di sonno. Tieni la fascia collegata e sincronizza di nuovo; la notte precedente qui sotto resta l’ultimo sonno rilevato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja zakończyła się, ale NOOP nie zdołał wiarygodnie określić okna snu. Pozostaw opaskę połączoną i zsynchronizuj ponownie; starsza noc poniżej nadal jest ostatnim wykrytym snem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A sincronização terminou, mas a NOOP não conseguiu identificar com confiança um período de sono. Mantém a bracelete ligada e sincroniza novamente; a noite anterior abaixo continua a ser o teu último sono detetado." } }, "ru": { "stringUnit": { "state": "translated", "value": "Синхронизация завершена, но NOOP не смог уверенно определить окно сна. Оставьте браслет подключённым и повторите синхронизацию; более ранняя ночь ниже остаётся последним обнаруженным сном." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 无法可靠识别睡眠时段。请保持手环连接并再次同步;下方较早的一夜仍是最近一次检测到的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 無法可靠識別睡眠時段。請保持手環連接並再次同步;下方較早的一夜仍是最近一次偵測到的睡眠。" } }
+ } },
"RHR +%lld": { "localizations": {
"de": { "stringUnit": { "state": "translated", "value": "Ruhepuls +%lld" } }, "en": { "stringUnit": { "state": "translated", "value": "RHR +%lld" } }, "es": { "stringUnit": { "state": "translated", "value": "FC en reposo +%lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "FC au repos +%lld" } }, "it": { "stringUnit": { "state": "translated", "value": "FC a riposo +%lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno spocz. +%lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "FC em repouso +%lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс в покое +%lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "静息心率 +%lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "靜息心率 +%lld" } }
} },
"HRV −%lld%%": { "localizations": {
"de": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "en": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "es": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "fr": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "it": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "pl": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "ru": { "stringUnit": { "state": "translated", "value": "ВСР −%lld%%" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }
} },
- "Skin temperature +%@ °C": { "localizations": {
- "de": { "stringUnit": { "state": "translated", "value": "Hauttemperatur +%@ °C" } }, "en": { "stringUnit": { "state": "translated", "value": "Skin temperature +%@ °C" } }, "es": { "stringUnit": { "state": "translated", "value": "Temperatura de la piel +%@ °C" } }, "fr": { "stringUnit": { "state": "translated", "value": "Température cutanée +%@ °C" } }, "it": { "stringUnit": { "state": "translated", "value": "Temperatura cutanea +%@ °C" } }, "pl": { "stringUnit": { "state": "translated", "value": "Temperatura skóry +%@ °C" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Temperatura da pele +%@ °C" } }, "ru": { "stringUnit": { "state": "translated", "value": "Температура кожи +%@ °C" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "皮肤温度 +%@ °C" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "皮膚溫度 +%@ °C" } }
+ "Skin temperature %@": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Hauttemperatur %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Skin temperature %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Temperatura de la piel %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Température cutanée %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Temperatura cutanea %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Temperatura skóry %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Temperatura da pele %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Температура кожи %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "皮肤温度 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "皮膚溫度 %@" } }
} },
"Respiration up": { "localizations": {
"de": { "stringUnit": { "state": "translated", "value": "Atemfrequenz erhöht" } }, "en": { "stringUnit": { "state": "translated", "value": "Respiration up" } }, "es": { "stringUnit": { "state": "translated", "value": "Respiración elevada" } }, "fr": { "stringUnit": { "state": "translated", "value": "Respiration élevée" } }, "it": { "stringUnit": { "state": "translated", "value": "Respirazione elevata" } }, "pl": { "stringUnit": { "state": "translated", "value": "Oddech podwyższony" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Respiração elevada" } }, "ru": { "stringUnit": { "state": "translated", "value": "Частота дыхания повышена" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "呼吸频率升高" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "呼吸頻率升高" } }
@@ -54,6 +120,9 @@
"zh-Hans": { "stringUnit": { "state": "translated", "value": "部分信号升高,但你记录了%@。这更可能是原因。设备端估算,不是诊断。" } },
"zh-Hant": { "stringUnit": { "state": "translated", "value": "部分訊號升高,但你記錄了%@。這更可能是原因。裝置端估算,不是診斷。" } }
} },
+ "The line breaks where the estimation method changed or was not recorded.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Die Linie bricht dort ab, wo sich die Schätzmethode geändert hat oder nicht erfasst wurde." } }, "en": { "stringUnit": { "state": "translated", "value": "The line breaks where the estimation method changed or was not recorded." } }, "es": { "stringUnit": { "state": "translated", "value": "La línea se interrumpe donde el método de estimación cambió o no se registró." } }, "fr": { "stringUnit": { "state": "translated", "value": "La courbe est interrompue là où la méthode d’estimation a changé ou n’a pas été enregistrée." } }, "it": { "stringUnit": { "state": "translated", "value": "La linea si interrompe dove il metodo di stima è cambiato o non è stato registrato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Linia jest przerwana tam, gdzie metoda szacowania zmieniła się lub nie została zapisana." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A linha é interrompida onde o método de estimativa mudou ou não foi registado." } }, "ru": { "stringUnit": { "state": "translated", "value": "Линия прерывается там, где метод оценки изменился или не был записан." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "线条在估算方法发生变化或未记录之处中断。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "線條在估算方法變更或未記錄之處中斷。" } }
+ } },
"A few signals are mildly up: %@. Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis.": { "localizations": {
"de": { "stringUnit": { "state": "translated", "value": "Einige Signale sind leicht erhöht: %@. Kein Grund zur Sorge, aber ein ruhigerer Tag könnte helfen. Schätzung auf dem Gerät, keine Diagnose." } }, "en": { "stringUnit": { "state": "translated", "value": "A few signals are mildly up: %@. Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis." } }, "es": { "stringUnit": { "state": "translated", "value": "Algunas señales están ligeramente elevadas: %@. No es alarmante, pero un día más tranquilo podría ayudar. Estimación en el dispositivo, no es un diagnóstico." } }, "fr": { "stringUnit": { "state": "translated", "value": "Quelques signaux sont légèrement élevés : %@. Rien d’alarmant, mais une journée plus calme peut aider. Estimation sur l’appareil, pas un diagnostic." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Alguns sinais estão ligeiramente elevados: %@. Não é alarmante, mas um dia mais calmo pode ajudar. Estimativa no dispositivo, não é um diagnóstico." } },
"it": { "stringUnit": { "state": "translated", "value": "Alcuni segnali sono leggermente elevati: %@. Nulla di allarmante, ma una giornata più tranquilla può aiutare. Stima sul dispositivo, non è una diagnosi." } },
@@ -676,6 +745,12 @@
"state": "translated",
"value": "%@ · typowo %@ do %@, %lld."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": " %@ · обычно от %@ до %@, %lld."
+ }
}
}
},
@@ -1006,6 +1081,12 @@
"state": "translated",
"value": "%.2f V"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%.2f В"
+ }
}
}
},
@@ -7377,6 +7458,12 @@
"state": "translated",
"value": "%@: %@. Za mało historii dla typowego asortymentu."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@: %@. Пока недостаточно истории для типичного диапазона."
+ }
}
}
},
@@ -8559,6 +8646,12 @@
"state": "translated",
"value": "%lld kilka dni temu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld дней назад"
+ }
}
}
},
@@ -13630,6 +13723,12 @@
"state": "translated",
"value": "%lld-%lld%@ · krok %lld"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld–%lld%@ · шаг %lld"
+ }
}
}
},
@@ -14262,6 +14361,12 @@
"state": "translated",
"value": "%lld–%lld bpm"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld–%lld уд/мин"
+ }
}
}
},
@@ -14836,6 +14941,12 @@
"state": "translated",
"value": "Skala 0-%lld"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Шкала 0–%lld"
+ }
}
}
},
@@ -15798,6 +15909,12 @@
"state": "translated",
"value": "1 minuta"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "1 мин"
+ }
}
}
},
@@ -16946,6 +17063,12 @@
"state": "translated",
"value": "1 tydzień"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "1 неделя"
+ }
}
}
},
@@ -16980,6 +17103,12 @@
"state": "translated",
"value": "1 trening"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "1 тренировка"
+ }
}
}
},
@@ -17594,6 +17723,12 @@
"state": "translated",
"value": "2 dni"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "2 дня"
+ }
}
}
},
@@ -17628,6 +17763,12 @@
"state": "translated",
"value": "2 minuty"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "2 мин"
+ }
}
}
},
@@ -17662,6 +17803,12 @@
"state": "translated",
"value": "2 tygodnie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "2 недели"
+ }
}
}
},
@@ -18282,6 +18429,12 @@
"state": "translated",
"value": "5 min"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "5 мин"
+ }
}
}
},
@@ -18664,6 +18817,12 @@
"state": "translated",
"value": "Wartość bazowa 7 nocy %lld ms, normalna %lld do %lld ms%@"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Норма за 7 ночей %lld мс, обычный диапазон %lld–%lld мс%@"
+ }
}
}
},
@@ -19302,6 +19461,12 @@
"state": "translated",
"value": "Skrót chce dodać dni %lld i treningi %lld do źródła importu Apple Health."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Команда хочет добавить %lld дней и %lld тренировок в источник импорта Apple Health."
+ }
}
}
},
@@ -19336,6 +19501,12 @@
"state": "translated",
"value": "Skrót chce dodać dane do źródła importu Apple Health."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Команда хочет добавить данные в источник импорта Apple Health."
+ }
}
}
},
@@ -21504,6 +21675,12 @@
"state": "translated",
"value": "Szturchnięcie raz dziennie, gdy Twój Wysiłek osiągnie dolną granicę dzisiejszego optymalnego zakresu obciążenia, obliczonego na podstawie Twojego powrotu do zdrowia."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Одно напоминание в день, когда ваше Усилие достигает нижней границы сегодняшнего оптимального диапазона нагрузки, рассчитанного по вашему восстановлению."
+ }
}
}
},
@@ -21815,7 +21992,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ein Read-Only Plews / Altini am kleinsten Wert während der Änderung Ihrer nächtlichen HRV: Es zeigt an, ob Ihre 7-Nächte-HRV-Basislinie über, innerhalb oder unter Ihrem persönlichen normalen Band liegt. Es ändert nichts anderes - der Energiering ist identisch, ob dieser ein- oder ausgeschaltet ist. Dies ist eine grobe / frühe Prüfung, die noch nicht gegen unterschiedliche reale Daten (n = 1) validiert wurde."
+ "value": "Ein Read-Only Plews / Altini am kleinsten Wert während der Änderung deiner nächtlichen HRV: Es zeigt an, ob deine 7-Nächte-HRV-Basislinie über, innerhalb oder unter deinem persönlichen normalen Band liegt. Es ändert nichts anderes - der Energiering ist identisch, ob dieser ein- oder ausgeschaltet ist. Dies ist eine grobe / frühe Prüfung, die noch nicht gegen unterschiedliche reale Daten (n = 1) validiert wurde."
}
},
"es": {
@@ -21841,6 +22018,12 @@
"state": "translated",
"value": "Odczyt tylko do odczytu Plews/Altini dotyczący najmniejszej i wartej uwagi zmiany twojego nocnego HRV: pokazuje, czy twoja 7-dniowa linia bazowa HRV znajduje się powyżej, wewnątrz czy poniżej twojego osobistego normalnego pasma. Nie zmienia to niczego innego - pierścień Energia jest identyczny niezależnie od tego, czy jest on włączony, czy wyłączony. Są to przybliżone/wczesne testy, które nie zostały jeszcze zweryfikowane w oparciu o różne rzeczywiste dane (n=1)."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Показатель ночной ВСР по методу наименьшего значимого изменения Plews/Altini, только для чтения: показывает, находится ли ваша 7-ночная норма ВСР выше, внутри или ниже вашего личного нормального диапазона. Больше он ничего не меняет — кольцо Заряда одинаково при включённой и выключенной настройке. Это черновое, раннее тестирование, ещё не проверенное на разнообразных реальных данных (n=1)."
+ }
}
}
},
@@ -22552,7 +22735,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ein transparentes kardiorespiratorisches Rezept, das sich tief und REM besser erholt als das ältere V1-Staging und jetzt standardmäßig ist. Es ändert sich nur, wie bereits erkannte Nächte in Etappen unterteilt sind (Erkennung und Ergebnisse sind unverändert); Schalten Sie es aus, um auf V1 zurückzugreifen. Tritt in den nächsten Nächten inszeniert."
+ "value": "Ein transparentes kardiorespiratorisches Rezept, das sich tief und REM besser erholt als das ältere V1-Staging und jetzt standardmäßig ist. Es ändert sich nur, wie bereits erkannte Nächte in Etappen unterteilt sind (Erkennung und Ergebnisse sind unverändert); Schalte es aus, um auf V1 zurückzugreifen. Tritt in den nächsten Nächten inszeniert."
}
},
"es": {
@@ -22578,6 +22761,12 @@
"state": "translated",
"value": "Przejrzysta receptura krążeniowo-oddechowa, która regeneruje głębiej i w fazie REM lepiej niż starsza wersja V1 i jest teraz domyślna. Zmienia się jedynie sposób podziału już wykrytych nocy na etapy (wykrywanie i wyniki pozostają niezmienione); wyłącz go, aby powrócić do wersji V1. Obowiązuje od kolejnych nocy scenicznych."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Прозрачная кардиореспираторная методика, которая восстанавливает глубокий сон и REM лучше прежней разметки V1 и теперь используется по умолчанию. Она меняет только то, как уже определённые ночи делятся на стадии (определение и оценки не меняются); выключите, чтобы вернуться к V1. Вступает в силу для следующих размеченных ночей."
+ }
}
}
},
@@ -25894,7 +26083,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Fügen Sie Ihre Oura-App-Anmeldeinformationen zu OuraSecrets.xcconfig hinzu, um dies zu ermöglichen."
+ "value": "Füge deine Oura-App-Anmeldeinformationen zu OuraSecrets.xcconfig hinzu, um dies zu ermöglichen."
}
},
"es": {
@@ -25920,6 +26109,12 @@
"state": "translated",
"value": "Dodaj poświadczenia aplikacji Oura do pliku OuraSecrets.xcconfig, aby to włączyć."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Добавьте учётные данные приложения Oura в OuraSecrets.xcconfig, чтобы включить это."
+ }
}
}
},
@@ -26650,6 +26845,12 @@
"state": "translated",
"value": "Po wysiłku o dużej intensywności"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "После высокоинтенсивной нагрузки"
+ }
}
}
},
@@ -27288,6 +27489,12 @@
"state": "translated",
"value": "Wszystkie działania"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Все активности"
+ }
}
}
},
@@ -29704,7 +29911,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Bewaffnet am Riemen selbst mit dem experimentellen Befehl 5/MG. Ein gurtgetriebener Wake ist immer noch unbestätigt auf 5/MG auf unserer Seite (bestätigt nur auf WHOOP 4.0), also halten Sie einen Backup-Alarm für alles, was Sie wirklich nicht verpassen können."
+ "value": "Bewaffnet am Riemen selbst mit dem experimentellen Befehl 5/MG. Ein gurtgetriebener Wake ist immer noch unbestätigt auf 5/MG auf unserer Seite (bestätigt nur auf WHOOP 4.0), also halte einen Backup-Alarm für alles, was du wirklich nicht verpassen kannst."
}
},
"es": {
@@ -29730,6 +29937,12 @@
"state": "translated",
"value": "Uzbrojony w sam pasek za pomocą eksperymentalnego polecenia 5/MG. Budzenie napędzane paskiem jest nadal niepotwierdzone na 5/MG po naszej stronie (potwierdzone tylko na WHOOP 4.0), więc miej alarm zapasowy na wypadek wszystkiego, czego naprawdę nie możesz przegapić."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Устанавливается на самом браслете экспериментальной командой 5/MG. Пробуждение с браслета на 5/MG у нас пока не подтверждено (подтверждено только на WHOOP 4.0), поэтому оставьте резервный будильник для всего, что действительно нельзя пропустить."
+ }
}
}
},
@@ -29738,7 +29951,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Bewaffnet auf dem gurt selbst, so dass es zu ihrer wachzeit summen kann, auch wenn ihr telefon schläft oder xnoopx geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halten Sie einen Backup-Alarm für alles, was Sie wirklich nicht verpassen können."
+ "value": "Bewaffnet auf dem gurt selbst, so dass es zu ihrer wachzeit summen kann, auch wenn ihr telefon schläft oder xnoopx geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halte einen Backup-Alarm für alles, was du wirklich nicht verpassen kannst."
}
},
"es": {
@@ -29764,6 +29977,12 @@
"state": "translated",
"value": "Uzbrojony w sam pasek, dzięki czemu może wibrować, gdy się obudzisz, nawet jeśli Twój telefon jest uśpiony lub NOOP jest zamknięty. Wysyła dokładną komendę alarmową wysyłaną przez oficjalną aplikację, potwierdzone brzęczenie na prawdziwym WHOOP 4.0 (przechwytywanie połączeń społecznościowych + test na urządzeniu, #535). Zachowaj alarm zapasowy na wypadek wszystkiego, czego naprawdę nie możesz przegapić."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Устанавливается на самом браслете, поэтому он может завибрировать в час пробуждения, даже если телефон спит или NOOP закрыт. Отправляет ровно ту команду будильника, что и официальное приложение; вибрация подтверждена на настоящем WHOOP 4.0 (перехват трафика сообществом + проверка на устройстве, #535). Оставьте резервный будильник для всего, что действительно нельзя пропустить."
+ }
}
}
},
@@ -29830,7 +30049,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Armiert den Riemen, um zu Ihrer Weckzeit zu summen, auch wenn NOOP geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halten Sie einen Backup-Alarm für alles, was Sie wirklich nicht verpassen können."
+ "value": "Armiert den Riemen, um zu deiner Weckzeit zu summen, auch wenn NOOP geschlossen ist. Sendet den genauen Alarmbefehl, den die offizielle App sendet, bestätigtes Summen auf einem echten WHOOP 4.0 (Community-Wire-Capture + On-Device-Test, #535). Halte einen Backup-Alarm für alles, was du wirklich nicht verpassen kannst."
}
},
"es": {
@@ -29856,6 +30075,12 @@
"state": "translated",
"value": "Uzbraja pasek, aby brzęczał w momencie przebudzenia, nawet jeśli NOOP jest zamknięty. Wysyła dokładną komendę alarmową wysyłaną przez oficjalną aplikację, potwierdzone brzęczenie na prawdziwym WHOOP 4.0 (przechwytywanie połączeń społecznościowych + test na urządzeniu, #535). Zachowaj alarm zapasowy na wypadek wszystkiego, czego naprawdę nie możesz przegapić."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Настраивает браслет на вибрацию в час пробуждения, даже если NOOP закрыт. Отправляет ровно ту команду будильника, что и официальное приложение; вибрация подтверждена на настоящем WHOOP 4.0 (перехват трафика сообществом + проверка на устройстве, #535). Оставьте резервный будильник для всего, что действительно нельзя пропустить."
+ }
}
}
},
@@ -29937,6 +30162,12 @@
"value": "Organiser Aujourd'hui"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Настроить «Сегодня»"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -34524,6 +34755,12 @@
"state": "translated",
"value": "Napięcie akumulatora %.2f w woltach"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Напряжение батареи %.2f вольт"
+ }
}
}
},
@@ -34558,6 +34795,12 @@
"state": "translated",
"value": "Sonda informacji o akumulatorze (#592 RE)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проба сведений о батарее (#592 RE)"
+ }
}
}
},
@@ -34592,6 +34835,12 @@
"state": "translated",
"value": "Sonda informacji o akumulatorze (#592 RE)…"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проба сведений о батарее (#592 RE)…"
+ }
}
}
},
@@ -34626,6 +34875,12 @@
"state": "translated",
"value": "Wynik sondy informacji o akumulatorze (#592)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Результат пробы батареи (#592)"
+ }
}
}
},
@@ -36552,6 +36807,12 @@
"state": "translated",
"value": "Sonda do lokalizacji ciała (#690 RE)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проба положения на теле (#690 RE)"
+ }
}
}
},
@@ -36586,6 +36847,12 @@
"state": "translated",
"value": "Sonda do lokalizacji ciała (#690 RE)…"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проба положения на теле (#690 RE)…"
+ }
}
}
},
@@ -36620,6 +36887,12 @@
"state": "translated",
"value": "Wynik sondy lokalizacji ciała (#690)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Результат пробы положения на теле (#690)"
+ }
}
}
},
@@ -37806,7 +38079,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Geben Sie einen Marker CSV (Datum, Marker, Wert, Einheit). Namen, die mit der Katalogfaltung auf Ihre vorhandenen Markierungen übereinstimmen; alles andere kommt als benutzerdefinierte Markierung vor. Zeilen, die nicht gelesen werden können, werden übersprungen und gezählt, nie erraten. Alles, was Sie importieren, bleibt auf %1$@."
+ "value": "Gib einen Marker CSV (Datum, Marker, Wert, Einheit). Namen, die mit der Katalogfaltung auf deine vorhandenen Markierungen übereinstimmen; alles andere kommt als benutzerdefinierte Markierung vor. Zeilen, die nicht gelesen werden können, werden übersprungen und gezählt, nie erraten. Alles, was du importierst, bleibt auf %1$@."
}
},
"es": {
@@ -37832,6 +38105,12 @@
"state": "translated",
"value": "Wprowadź znaczniki CSV (data, znacznik, wartość, jednostka). Nazwy pasujące do katalogu należy złożyć na istniejące znaczniki; wszystko inne pojawia się jako znacznik niestandardowy. Wiersze, których nie można odczytać, są pomijane i liczone, nigdy nie zgadywane. Wszystko, co importujesz, pozostaje w %@."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Загрузите CSV с маркерами (дата, маркер, значение, единица). Названия, совпадающие с каталогом, объединяются с вашими существующими маркерами; всё остальное добавляется как свой маркер. Строки, которые не удалось прочитать, пропускаются и подсчитываются — никогда не угадываются. Всё импортированное остаётся на %@."
+ }
}
}
},
@@ -37913,6 +38192,12 @@
"value": "Diffusion des ressources humaines de ce téléphone"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Транслировать пульс с этого телефона"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -38185,6 +38470,12 @@
"value": "Sangle de diffusion HR (Garmin/ANT)"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Транслировать пульс браслета (Garmin/ANT)"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -41332,6 +41623,12 @@
"state": "translated",
"value": "Kalibracja (%lld/%lld w nocy)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Калибровка (%lld из %lld ночей)"
+ }
}
}
},
@@ -42526,6 +42823,9 @@
}
}
},
+ "Carried · %@": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Übernommen · %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Heredado · %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Reporté · %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Riportato · %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przeniesione · %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Herdado · %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Перенесено · %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "携带 · %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "攜带 · %@" } }
+ } },
"Carbs": {
"localizations": {
"de": {
@@ -43091,6 +43391,12 @@
"state": "translated",
"value": "Zmień na numer"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Сменить на число"
+ }
}
}
},
@@ -43125,6 +43431,12 @@
"state": "translated",
"value": "Zmień na Tak/Nie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Сменить на «Да/Нет»"
+ }
}
}
},
@@ -44305,7 +44617,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Energie gewichtet Ihre HRV gegenüber Ihrer persönlichen Basislinie (~55%%), Ruheherzfrequenz (~20%%), Erholungsqualität (~15%%), Atmung (~5%%) und Hauttemperaturabweichung (~5%%). Belastung ist eine kardiovaskuläre Belastung von 0–%lld aus der Zeit in Herzfrequenzzonen. Erholung wird aus Bewegung und Herzfrequenz in Schlafphasen eingeteilt. Alles wird hier aus den Rohdaten des Straps berechnet."
+ "value": "Energie gewichtet deine HRV gegenüber deiner persönlichen Basislinie (~55%%), Ruheherzfrequenz (~20%%), Erholungsqualität (~15%%), Atmung (~5%%) und Hauttemperaturabweichung (~5%%). Belastung ist eine kardiovaskuläre Belastung von 0–%lld aus der Zeit in Herzfrequenzzonen. Erholung wird aus Bewegung und Herzfrequenz in Schlafphasen eingeteilt. Alles wird hier aus den Rohdaten des Straps berechnet."
}
},
"es": {
@@ -44331,6 +44643,12 @@
"state": "translated",
"value": "Energia porównuje Twój HRV z Twoim osobistym poziomem bazowym (~55%%), tętnem spoczynkowym (~20%%), jakością odpoczynku (~15%%), oddychaniem (~5%%) i odchyleniem temperatury skóry (~5%%). Wysiłek to obciążenie układu krążenia 0-%lld od czasu w strefach tętna. Odpoczynek jest inscenizowany na podstawie ruchu i tętna. Wszystko jest tutaj obliczane na podstawie surowych danych paska. Działa każdego dnia NOOP zbiera surowe strumienie."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Заряд сопоставляет вашу ВСР с личной нормой (~55%%), пульс в покое (~20%%), качество отдыха (~15%%), дыхание (~5%%) и отклонение температуры кожи (~5%%). Усилие — это сердечно-сосудистая нагрузка 0–%lld по времени в зонах пульса. Отдых размечается по движению и пульсу. Всё вычисляется здесь по сырым данным браслета. Работает для любого дня, за который NOOP собрал сырые потоки."
+ }
}
}
},
@@ -50640,6 +50958,390 @@
}
}
},
+ "Live heart rate is streaming, but your strap is not fully paired. Buzz, alarms and history sync need the encrypted pairing.": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Die Live-Herzfrequenz wird übertragen, aber dein Strap ist nicht vollständig gekoppelt. Vibration, Wecker und Verlaufssynchronisierung benötigen die verschlüsselte Kopplung."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Live heart rate is streaming, but your strap is not fully paired. Buzz, alarms and history sync need the encrypted pairing."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "La frecuencia cardíaca en tiempo real se está transmitiendo, pero tu pulsera no está completamente emparejada. La vibración, las alarmas y la sincronización del historial necesitan el emparejamiento cifrado."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "La fréquence cardiaque en direct est diffusée, mais votre bracelet n'est pas entièrement associé. Les vibrations, les alarmes et la synchronisation de l'historique nécessitent l'association chiffrée."
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "La frequenza cardiaca in tempo reale è in streaming, ma la tua fascia non è completamente associata. Vibrazione, sveglie e sincronizzazione della cronologia richiedono l'associazione cifrata."
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tętno na żywo jest przesyłane, ale twój pasek nie jest w pełni sparowany. Wibracje, alarmy i synchronizacja historii wymagają szyfrowanego parowania."
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A frequência cardíaca em tempo real está a ser transmitida, mas a tua bracelete não está totalmente emparelhada. A vibração, os alarmes e a sincronização do histórico necessitam do emparelhamento encriptado."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пульс в реальном времени передаётся, но ваш браслет сопряжён не полностью. Вибрация, будильники и синхронизация истории требуют зашифрованного сопряжения."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "实时心率正在传输,但你的手环尚未完全配对。震动、闹钟和历史同步需要加密配对。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "即時心率正在傳輸,但你的手環尚未完全配對。震動、鬧鐘和歷史同步需要加密配對。"
+ }
+ }
+ }
+ },
+ "Check automatically": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Automatisch prüfen"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Check automatically"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Comprobar automáticamente"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Vérifier automatiquement"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Controlla automaticamente"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sprawdzaj automatycznie"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Verificar automaticamente"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проверять автоматически"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "自动检查"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "自動檢查"
+ }
+ }
+ }
+ },
+ "Once a day, NOOP asks GitHub for the latest version number and puts a note in Updates if there's a newer one. Nothing about you is sent, and it never installs anything.": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Einmal täglich fragt NOOP bei GitHub die neueste Versionsnummer ab und legt einen Hinweis in Updates ab, wenn es eine neuere gibt. Es werden keine Daten über dich gesendet, und es wird nie etwas installiert."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Once a day, NOOP asks GitHub for the latest version number and puts a note in Updates if there's a newer one. Nothing about you is sent, and it never installs anything."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Una vez al día, NOOP consulta a GitHub el número de la última versión y deja un aviso en Novedades si hay una más reciente. No se envía nada sobre ti y nunca instala nada."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Une fois par jour, NOOP demande à GitHub le numéro de la dernière version et dépose une note dans Mises à jour s'il en existe une plus récente. Aucune donnée vous concernant n'est envoyée, et rien n'est jamais installé."
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Una volta al giorno, NOOP chiede a GitHub il numero dell'ultima versione e lascia una nota in Aggiornamenti se ne esiste una più recente. Non viene inviato nulla su di te e non installa mai nulla."
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Raz dziennie NOOP pyta GitHub o numer najnowszej wersji i dodaje notatkę w Aktualizacjach, jeśli jest nowsza. Nic o tobie nie jest wysyłane i nic nigdy nie jest instalowane."
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Uma vez por dia, o NOOP pergunta ao GitHub o número da versão mais recente e deixa uma nota em Atualizações se existir uma mais recente. Não é enviado nada sobre si e nunca instala nada."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Раз в день NOOP запрашивает у GitHub номер последней версии и оставляет заметку в «Обновлениях», если появилась более новая. Ничего о вас не отправляется, и ничего никогда не устанавливается."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP 每天向 GitHub 查询一次最新版本号,如果有更新版本,就在「更新」中留下提示。不会发送任何与你有关的信息,也绝不会安装任何东西。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP 每天向 GitHub 查詢一次最新版本號,如果有更新版本,就在「更新」中留下提示。不會傳送任何與你有關的資訊,也絕不會安裝任何東西。"
+ }
+ }
+ }
+ },
+ "NOOP %@ is available": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ ist verfügbar"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ is available"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ ya está disponible"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ est disponible"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ è disponibile"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ jest dostępny"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "O NOOP %@ está disponível"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Доступна версия NOOP %@"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ 可用"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP %@ 可用"
+ }
+ }
+ }
+ },
+ "You're on %@. Open Settings and use Check for updates to see what's new and download %@.": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Du nutzt %@. Öffne die Einstellungen und tippe auf „Nach Updates suchen“, um die Neuerungen zu sehen und %@ herunterzuladen."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "You're on %@. Open Settings and use Check for updates to see what's new and download %@."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tienes la %@. Abre Ajustes y usa Buscar actualizaciones para ver las novedades y descargar la %@."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Vous utilisez la version %@. Ouvrez les Réglages et utilisez Rechercher des mises à jour pour voir les nouveautés et télécharger la %@."
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Stai usando la %@. Apri Impostazioni e usa Cerca aggiornamenti per vedere le novità e scaricare la %@."
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Masz wersję %@. Otwórz Ustawienia i użyj opcji Sprawdź aktualizacje, aby zobaczyć nowości i pobrać %@."
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tem a versão %@. Abra as Definições e use Procurar atualizações para ver as novidades e transferir a %@."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "У вас версия %@. Откройте «Настройки» и нажмите «Проверить обновления», чтобы посмотреть, что нового, и загрузить %@."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "你当前使用 %@。打开「设置」并使用「检查更新」查看新增内容并下载 %@。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "你目前使用 %@。開啟「設定」並使用「檢查更新」查看新增內容並下載 %@。"
+ }
+ }
+ }
+ },
+ "AltStore or SideStore can install it for you automatically if you added NOOP's source; a direct .ipa still has to be signed on your device.": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "AltStore oder SideStore installieren es automatisch für dich, wenn du NOOPs Quelle hinzugefügt hast; eine direkte .ipa muss weiterhin auf deinem Gerät signiert werden."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "AltStore or SideStore can install it for you automatically if you added NOOP's source; a direct .ipa still has to be signed on your device."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "AltStore o SideStore pueden instalarla automáticamente si añadiste la fuente de NOOP; un .ipa directo aún debe firmarse en tu dispositivo."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "AltStore ou SideStore peuvent l'installer automatiquement si vous avez ajouté la source de NOOP ; un .ipa direct doit toujours être signé sur votre appareil."
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "AltStore o SideStore possono installarla automaticamente se hai aggiunto la sorgente di NOOP; un .ipa diretto deve comunque essere firmato sul tuo dispositivo."
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "AltStore lub SideStore zainstalują ją automatycznie, jeśli dodano źródło NOOP; bezpośredni plik .ipa nadal trzeba podpisać na urządzeniu."
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "O AltStore ou o SideStore podem instalá-la automaticamente se adicionou a fonte do NOOP; um .ipa direto continua a ter de ser assinado no seu dispositivo."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "AltStore или SideStore установят её автоматически, если вы добавили источник NOOP; прямой .ipa всё равно нужно подписать на устройстве."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "如果你添加了 NOOP 的源,AltStore 或 SideStore 可以自动为你安装;直接下载的 .ipa 仍需在设备上签名。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "如果你加入了 NOOP 的來源,AltStore 或 SideStore 可以自動為你安裝;直接下載的 .ipa 仍需在裝置上簽名。"
+ }
+ }
+ }
+ },
"Connected. Finishing the secure pairing handshake…": {
"localizations": {
"de": {
@@ -55679,6 +56381,12 @@
"state": "translated",
"value": "Dzień 1: jasne światło %@-%@, wyłączenie świateł wokół %@."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "День 1: яркий свет %@–%@, отбой около %@."
+ }
}
}
},
@@ -57428,6 +58136,12 @@
"value": "Carreaux détaillés"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Подробные плитки"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -61111,6 +61825,12 @@
"state": "translated",
"value": "ALGORYTMY EKSPERYMENTALNE"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Экспериментальные алгоритмы"
+ }
}
}
},
@@ -61447,6 +62167,12 @@
"state": "translated",
"value": "Każda linia pokazuje, o ile uderzeń na minutę zmieniło się tętno po wysiłku. Uwzględniane są tylko treningi o wysokiej intensywności z zarejestrowanym tętnem po treningu."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Каждая линия показывает, на сколько ударов в минуту изменился ваш пульс после нагрузки. Учитываются только высокоинтенсивные тренировки с записанным пульсом после тренировки."
+ }
}
}
},
@@ -70222,6 +70948,12 @@
"state": "translated",
"value": "Zapomnij o dostępie Oura"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Забыть доступ к Oura"
+ }
}
}
},
@@ -72730,6 +73462,12 @@
"state": "translated",
"value": "Grupa"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Группа"
+ }
}
}
},
@@ -73118,6 +73856,12 @@
"state": "translated",
"value": "GODZINY SNU"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ЧАСОВ СНА"
+ }
}
}
},
@@ -73861,6 +74605,12 @@
"value": "Interpolation du sous-logement HR-from-PPG (v26)"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пульс из PPG: интерполяция внутри задержки (заполнение пропусков v26)"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -74487,6 +75237,12 @@
"value": "Préparation à la VFC (Plews/Altini)"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Готовность по ВСР (Plews/Altini)"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -74527,6 +75283,12 @@
"value": "Préparation à la VFC (expérimental)"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Готовность по ВСР (эксперимент)"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -74578,6 +75340,12 @@
"state": "translated",
"value": "Gotowość HRV (eksperymentalna): %@"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Готовность по ВСР (эксперимент): %@"
+ }
}
}
},
@@ -74717,6 +75485,12 @@
"value": "Fenêtre VFC"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Окно ВСР"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -75161,7 +75935,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Heads up: Dieser Testmodus ist ausgeschaltet, so dass der Bericht keine Erfassung dafür hat. Für einen nützlichen Bericht schalten Sie den Modus ein, reproduzieren das Problem beim Tragen des Riemens und melden Sie es erneut."
+ "value": "Heads up: Dieser Testmodus ist ausgeschaltet, so dass der Bericht keine Erfassung dafür hat. Für einen nützlichen Bericht schalte den Modus ein, reproduzieren das Problem beim Tragen des Riemens und melde es erneut."
}
},
"es": {
@@ -75187,6 +75961,12 @@
"state": "translated",
"value": "Uwaga: ten tryb testowy jest wyłączony, więc raport nie zawiera jego przechwytywania. Aby otrzymać przydatny raport, włącz tryb, odtwórz problem podczas noszenia paska, a następnie zgłoś ponownie."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Внимание: этот тестовый режим выключен, поэтому в отчёте нет соответствующего захвата. Чтобы отчёт был полезен, включите режим, воспроизведите проблему в надетом браслете и отправьте отчёт заново."
+ }
}
}
},
@@ -75709,6 +76489,12 @@
"state": "translated",
"value": "Tętno Regeneracja"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Восстановление пульса"
+ }
}
}
},
@@ -76155,6 +76941,12 @@
"state": "translated",
"value": "Odzyskiwanie tętna przy %@, %lld uderzeń na minutę"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Восстановление пульса на %@, %lld ударов в минуту"
+ }
}
}
},
@@ -76189,6 +76981,12 @@
"state": "translated",
"value": "Odzyskiwanie tętna przy %@, niedostępne"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Восстановление пульса на %@ — нет данных"
+ }
}
}
},
@@ -76635,6 +77433,12 @@
"state": "translated",
"value": "Trend odzyskiwania tętna w uderzeniach na minutę"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Тренд восстановления пульса в ударах в минуту"
+ }
}
}
},
@@ -76669,6 +77473,12 @@
"state": "translated",
"value": "Odzyskiwanie tętna · %@"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Восстановление пульса · %@"
+ }
}
}
},
@@ -77760,6 +78570,12 @@
"value": "L'histoire reste sous la question originale donc WHOOP importations toujours en ligne."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "История остаётся под исходным вопросом, чтобы импорт из WHOOP по-прежнему совпадал."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -79876,7 +80692,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Wie man das liest: HRV, Ruhe HR, Schlafdauer, Atemfrequenz und Hauttemperatur werden vom Band gemessen (Hauttemperatur wird als Abweichung von der eigenen Baseline angezeigt). Workouts ist die Anzahl der Aktivitäten, die Sie protokolliert haben oder die erkannt wurden. Recovery, Strain und Stress sind die eigenen On-Device-Scores von NOOP, keine klinischen Maßnahmen: Erholung ist eine tägliche Bereitschaft Komposit (HRV, Ruhe HR, Schlaf und Haut-Temper-Trend), Strain ist Herz-Kreislauf-Last von der Herzfrequenz abgeleitet, und Stress ist ein 0-3 autonome Last-Index von Ruhe HR und HRV."
+ "value": "Wie man das liest: HRV, Ruhe HR, Schlafdauer, Atemfrequenz und Hauttemperatur werden vom Band gemessen (Hauttemperatur wird als Abweichung von der eigenen Baseline angezeigt). Workouts ist die Anzahl der Aktivitäten, die du protokolliert hast oder die erkannt wurden. Recovery, Strain und Stress sind die eigenen On-Device-Scores von NOOP, keine klinischen Maßnahmen: Erholung ist eine tägliche Bereitschaft Komposit (HRV, Ruhe HR, Schlaf und Haut-Temper-Trend), Strain ist Herz-Kreislauf-Last von der Herzfrequenz abgeleitet, und Stress ist ein 0-3 autonome Last-Index von Ruhe HR und HRV."
}
},
"es": {
@@ -79902,6 +80718,12 @@
"state": "translated",
"value": "Jak to odczytać: HRV, tętno spoczynkowe, czas snu, częstość oddechów i temperatura skóry są mierzone za pomocą paska (temperatura skóry jest pokazana jako odchylenie od własnej wartości bazowej). Treningi to liczba zarejestrowanych lub wykrytych aktywności. Regeneracja, Obciążenie i Stres to własne wyniki NOOP na urządzeniu, a nie pomiary kliniczne: Regeneracja to połączenie codziennej gotowości (HRV, tętno spoczynkowe, trend snu i temperatury skóry), Obciążenie to obciążenie układu sercowo-naczyniowego obliczone na podstawie tętna, a Stres to wskaźnik obciążenia autonomicznego wynoszący 0-3 na podstawie tętna spoczynkowego i tętna HRV."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Как это читать: ВСР, пульс в покое, длительность сна, частота дыхания и температура кожи измеряются браслетом (температура кожи показана как отклонение от вашей собственной нормы). «Тренировки» — количество активностей, которые вы записали или которые были определены автоматически. Восстановление, Нагрузка и Стресс — собственные оценки NOOP на устройстве, а не клинические показатели: Восстановление — это суточный сводный показатель готовности (ВСР, пульс в покое, сон и тренд температуры кожи), Нагрузка — сердечно-сосудистая нагрузка, выведенная из пульса, а Стресс — индекс вегетативной нагрузки 0–3 по пульсу в покое и ВСР."
+ }
}
}
},
@@ -81863,6 +82685,12 @@
"state": "translated",
"value": "Zaimportować dane Apple Health?"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Импортировать данные Apple Health?"
+ }
}
}
},
@@ -82187,6 +83015,12 @@
"state": "translated",
"value": "Zaimportuj ponownie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Импортировать снова"
+ }
}
}
},
@@ -85283,6 +86117,12 @@
"value": "Rappel du journal"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Напоминание о журнале"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -85671,6 +86511,12 @@
"value": "Garder les derniers instantanés"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Хранить последние снимки"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -86181,6 +87027,12 @@
"value": "Démarrage à (batterie)"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Включать при (заряде браслета)"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -90276,6 +91128,12 @@
"state": "translated",
"value": "Liquid Today (prototyp)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Liquid Today (прототип)"
+ }
}
}
},
@@ -90769,6 +91627,12 @@
"value": "Sessions en direct (bêta)"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Сессии в реальном времени (бета)"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -92998,6 +93862,12 @@
"value": "Remplir le journal du jour"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Заполнить журнал за сегодня"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -93688,6 +94558,12 @@
"value": "Des plages plus longues déverrouillent comme plus d'histoire construit."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Более длинные диапазоны открываются по мере накопления истории."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -93745,6 +94621,12 @@
"state": "translated",
"value": "Najdłuższy: %lld dni"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Дольше всего: %lld дней"
+ }
}
}
},
@@ -93785,6 +94667,12 @@
"state": "translated",
"value": "Najdłuższy: 1 dzień"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Дольше всего: 1 день"
+ }
}
}
},
@@ -94508,6 +95396,39 @@
}
}
},
+ "Day starts": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Tagesbeginn" } },
+ "es": { "stringUnit": { "state": "translated", "value": "El día empieza" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Début de la journée" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Inizio giornata" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Początek dnia" } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "O dia começa" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Начало дня" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "一天开始于" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "一天開始於" } }
+ } },
+ "Default. Steps and in-progress Effort restart at the beginning of detected main sleep. Naps do not start a new day; missing sleep falls back to local midnight.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Standard. Schritte und die laufende Anstrengung beginnen mit dem erkannten Hauptschlaf neu. Nickerchen starten keinen neuen Tag; bei fehlendem Schlaf verwendet NOOP ersatzweise die lokale Mitternacht." } },
+ "es": { "stringUnit": { "state": "translated", "value": "Predeterminado. Los pasos y el Esfuerzo en curso se reinician al comienzo del sueño principal detectado. Las siestas no empiezan un nuevo día; si faltan datos de sueño, se usa la medianoche local." } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Par défaut. Les pas et l’Effort en cours repartent de zéro au début du sommeil principal détecté. Les siestes ne commencent pas une nouvelle journée ; en l’absence de sommeil, l’heure locale de minuit est utilisée." } },
+ "it": { "stringUnit": { "state": "translated", "value": "Predefinito. I passi e lo Sforzo in corso ripartono all’inizio del sonno principale rilevato. I sonnellini non iniziano un nuovo giorno; se mancano dati sul sonno, viene usata la mezzanotte locale." } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Domyślnie. Kroki i bieżący Wysiłek zaczynają się od nowa wraz z wykrytym głównym snem. Drzemki nie rozpoczynają nowego dnia; gdy brakuje danych o śnie, używana jest lokalna północ." } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "Predefinição. Os passos e o Esforço em curso recomeçam no início do sono principal detetado. As sestas não iniciam um novo dia; se faltarem dados de sono, é usada a meia-noite local." } },
+ "ru": { "stringUnit": { "state": "translated", "value": "По умолчанию. Шаги и текущая Нагрузка обнуляются в начале распознанного основного сна. Дневной сон не начинает новый день; при отсутствии данных о сне используется местная полночь." } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "默认。步数和进行中的体力消耗从检测到的主要睡眠开始时重新计算。小睡不会开始新的一天;缺少睡眠数据时,将使用当地午夜。" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "預設。步數和進行中的體力消耗從偵測到的主要睡眠開始時重新計算。小睡不會開始新的一天;缺少睡眠資料時,將使用當地午夜。" } }
+ } },
+ "Uses a conventional local calendar day from 00:00 to 00:00.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Verwendet einen herkömmlichen lokalen Kalendertag von 00:00 bis 00:00 Uhr." } },
+ "es": { "stringUnit": { "state": "translated", "value": "Usa un día natural local convencional de 00:00 a 00:00." } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Utilise une journée civile locale classique de 00:00 à 00:00." } },
+ "it": { "stringUnit": { "state": "translated", "value": "Usa un giorno di calendario locale convenzionale dalle 00:00 alle 00:00." } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Używa zwykłego lokalnego dnia kalendarzowego od 00:00 do 00:00." } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "Usa um dia civil local convencional das 00:00 às 00:00." } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Использует обычный местный календарный день с 00:00 до 00:00." } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "使用从 00:00 到次日 00:00 的常规当地日历日。" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "使用從 00:00 到次日 00:00 的一般當地日曆日。" } }
+ } },
"Main sleep": {
"localizations": {
"de": {
@@ -99064,6 +99985,12 @@
"value": "Raffinement de la sillage en mouvement"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Уточнение пробуждения по движению"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -99401,6 +100328,12 @@
"value": "Bouge de toute façon"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Всё равно перенести"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -99484,7 +100417,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Bewegen Sie diesen Schlaf?"
+ "value": "Bewege diesen Schlaf?"
}
},
"es": {
@@ -99499,6 +100432,12 @@
"value": "Bouge ce sommeil ?"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Перенести этот сон?"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -100534,7 +101473,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "NOOP bietet Ihnen drei tägliche Werte (Charge, Effort und Rest), jede auf einer 0-100-Skala. Sie werden aus den rohen Signalen Ihres Gurts mit veröffentlichten, von Experten begutachteten Sportwissenschaften aufgebaut und vollständig auf Ihrem Gerät berechnet. Sie sind NICHT die Ergebnisse von WHOOP: Wir haben keine privaten Algorithmen von WHOOP und tun nicht so, als ob. Sie zielen auf die gleichen drei Fragen mit Open Science, so dass sie normalerweise WHOOP in Richtung verfolgen, aber nicht mit Zahl für Zahl übereinstimmen. Und darum geht es."
+ "value": "NOOP bietet dir drei tägliche Werte (Charge, Effort und Rest), jede auf einer 0-100-Skala. Sie werden aus den rohen Signalen deines Gurts mit veröffentlichten, von Experten begutachteten Sportwissenschaften aufgebaut und vollständig auf deinem Gerät berechnet. Sie sind NICHT die Ergebnisse von WHOOP: Wir haben keine privaten Algorithmen von WHOOP und tun nicht so, als ob. Sie zielen auf die gleichen drei Fragen mit Open Science, so dass sie normalerweise WHOOP in Richtung verfolgen, aber nicht mit Zahl für Zahl übereinstimmen. Und darum geht es."
}
},
"es": {
@@ -100560,6 +101499,12 @@
"state": "translated",
"value": "NOOP daje trzy dzienne wyniki (Energia, Wysiłek i Odpoczynek), każdy w skali 0-100. Są one tworzone na podstawie surowych sygnałów Twojego paska na podstawie opublikowanych, recenzowanych wyników badań sportowych i obliczane w całości na Twoim urządzeniu. NIE są to wyniki WHOOP: nie mamy prywatnych algorytmów WHOOP i nie udajemy, że je mamy. Mają na celu te same trzy pytania, korzystając z otwartej nauki, więc zazwyczaj śledzą kierunek WHOOP, ale nie dopasowują liczby do liczby. I o to właśnie chodzi."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "NOOP даёт три ежедневные оценки (Заряд, Усилие и Отдых), каждая по шкале 0–100. Они строятся по сырым сигналам вашего браслета на основе опубликованной, прошедшей рецензирование спортивной науки и вычисляются целиком на вашем устройстве. Это НЕ оценки WHOOP: у нас нет закрытых алгоритмов WHOOP, и мы не притворяемся, что есть. Они отвечают на те же три вопроса средствами открытой науки, поэтому обычно совпадают с оценками WHOOP по направлению, но не сходятся число в число. В этом и смысл."
+ }
}
}
},
@@ -106045,6 +106990,12 @@
"state": "translated",
"value": "Brak szczegółów tętna tej nocy"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Нет данных о пульсе за эту ночь"
+ }
}
}
},
@@ -106833,6 +107784,12 @@
"state": "translated",
"value": "Żadnych nowych nocy z Twojego paska przez %lld dni. Sprawdź, czy jest podłączony i zapisuje dane."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Нет новых ночей с вашего браслета уже %lld дней. Проверьте, что он подключён и сохраняет данные."
+ }
}
}
},
@@ -107221,6 +108178,12 @@
"state": "translated",
"value": "Brak zdjęcia → rezerwowa osoba.przycięcie.koło"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Нет фото → запасной вариант person.crop.circle"
+ }
}
}
},
@@ -111854,6 +112817,12 @@
"state": "translated",
"value": "Powiadamiaj, kiedy zostanie osiągnięte optymalne napięcie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Уведомлять о достижении оптимальной нагрузки"
+ }
}
}
},
@@ -112907,7 +113876,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ältere Backups darüber hinaus werden viele beschnitten, älteste zuerst (≈ so viele Tage). Wenn Daten jemals korrumpiert werden, stellen Sie das Neueste wieder her."
+ "value": "Ältere Backups darüber hinaus werden viele beschnitten, älteste zuerst (≈ so viele Tage). Wenn Daten jemals korrumpiert werden, stelle das Neueste wieder her."
}
},
"es": {
@@ -112933,6 +113902,12 @@
"state": "translated",
"value": "Starsze kopie zapasowe przekraczające tę liczbę są usuwane w kolejności od najstarszej (≈ tyle dni). Jeśli dane kiedykolwiek ulegną uszkodzeniu, przywróć najnowszą wersję."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Более старые копии сверх этого числа удаляются, начиная с самых старых (≈ столько дней). Если данные когда-нибудь повредятся, восстановите самую свежую."
+ }
}
}
},
@@ -114837,6 +115812,12 @@
"value": "Ouvrir le journal"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Открыть журнал"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -115481,6 +116462,12 @@
"state": "translated",
"value": "Osiągnięto optymalne napięcie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Достигнута оптимальная нагрузка"
+ }
}
}
},
@@ -118330,6 +119317,12 @@
"value": "Capture de la VFC en pause"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Приостановить сбор ВСР"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -118509,6 +119502,12 @@
"state": "translated",
"value": "Szczyt %lld bpm"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пик %lld уд/мин"
+ }
}
}
},
@@ -120229,7 +121228,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Bitte bestätigen Sie diese:"
+ "value": "Bitte bestätige diese:"
}
},
"es": {
@@ -120244,6 +121243,12 @@
"value": "Veuillez confirmer chacun de ces éléments :"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пожалуйста, подтвердите каждый пункт:"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -120327,7 +121332,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Bitte lesen Sie die Punkte unten und bestätigen Sie dann jede Aussage."
+ "value": "Bitte lies die Punkte unten und bestätige dann jede Aussage."
}
},
"es": {
@@ -120342,6 +121347,12 @@
"value": "Veuillez lire les points ci-dessous, puis confirmer chaque déclaration."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пожалуйста, прочитайте пункты ниже, затем подтвердите каждое утверждение."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -120573,6 +121584,12 @@
"state": "translated",
"value": "Posty po zsynchronizowaniu paska i zdobyciu punktów NOOP w danym dniu — a nie dokładnie w chwili, gdy go przekroczysz. Najwyżej raz dziennie."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Появляется после синхронизации браслета и подсчёта оценок за день — не в ту самую секунду, когда вы пересекаете порог. Не чаще раза в день."
+ }
}
}
},
@@ -120596,6 +121613,12 @@
"value": "Mode d'économie d'énergie"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Режим энергосбережения"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -123700,6 +124723,12 @@
"state": "translated",
"value": "REGENERUJĄCY SEN"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ВОССТАНАВЛИВАЮЩИЙ СОН"
+ }
}
}
},
@@ -125171,7 +126200,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Lesen Sie Ihre Herzfrequenz, HRV, Blutsauerstoff, Atemfrequenz, Schlaf, Schritte und Energie direkt von Apple Health und schreiben Sie NOOP's Banddaten zurück: Schlafen Sie mit vollen Stadien, kontinuierlicher Herzfrequenz, Workouts und nächtlichen Vitalen. Alles bleibt auf %1$@."
+ "value": "Lies deine Herzfrequenz, HRV, Blutsauerstoff, Atemfrequenz, Schlaf, Schritte und Energie direkt von Apple Health und schreibe NOOP's Banddaten zurück: Schlafe mit vollen Stadien, kontinuierlicher Herzfrequenz, Workouts und nächtlichen Vitalen. Alles bleibt auf %1$@."
}
},
"es": {
@@ -125197,6 +126226,12 @@
"state": "translated",
"value": "Odczytaj tętno, HRV, zawartość tlenu we krwi, częstość oddechów, sen, kroki i energię prosto z Apple Health i zapisz z powrotem dane paska NOOP: sen z pełnymi etapami, ciągłe tętno, treningi i nocne parametry życiowe. Wszystko pozostaje na %@."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Читайте пульс, ВСР, насыщение крови кислородом, частоту дыхания, сон, шаги и энергию прямо из Apple Health и записывайте данные браслета NOOP обратно: сон с полными стадиями, непрерывный пульс, тренировки и ночные показатели. Всё остаётся на %@."
+ }
}
}
},
@@ -126259,6 +127294,12 @@
"state": "translated",
"value": "Odczyty"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Измерения"
+ }
}
}
},
@@ -126769,6 +127810,12 @@
"state": "translated",
"value": "Zrestartuj sondę (4.0 RE)…"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проба перезагрузки (4.0 RE)…"
+ }
}
}
},
@@ -128277,6 +129324,12 @@
"state": "translated",
"value": "Trend Regeneracja"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Тренд восстановления"
+ }
}
}
},
@@ -128782,6 +129835,12 @@
"state": "translated",
"value": "Odśwież wiek fitness"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Обновить фитнес-возраст"
+ }
}
}
},
@@ -130395,6 +131454,12 @@
"value": "Renommer l'élément"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Переименовать пункт"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -130435,6 +131500,12 @@
"value": "Renommer..."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Переименовать…"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -130692,7 +131763,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ersetzt die Registerkarte Heute durch das Prototyp-Redesign. Schalten Sie es jederzeit aus, um zum klassischen Dashboard zurückzukehren. Lies die gleichen live-daten von deinem strap."
+ "value": "Ersetzt die Registerkarte Heute durch das Prototyp-Redesign. Schalte es jederzeit aus, um zum klassischen Dashboard zurückzukehren. Lies die gleichen live-daten von deinem strap."
}
},
"es": {
@@ -130718,6 +131789,12 @@
"state": "translated",
"value": "Zastępuje zakładkę Dzisiaj przeprojektowanym prototypem. Wyłącz tę opcję w dowolnym momencie, aby powrócić do klasycznego pulpitu nawigacyjnego. Odczytuje te same dane w czasie rzeczywistym z Twojego paska."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Заменяет вкладку «Сегодня» прототипом нового дизайна. Выключите в любой момент, чтобы вернуться к классической панели. Читает те же живые данные с вашего браслета."
+ }
}
}
},
@@ -130984,6 +132061,12 @@
"state": "translated",
"value": "Alternatywy klasy badawczej / ulepszenia precyzyjne. Możliwość zapisania się, domyślnie wyłączona, niekliniczna."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Исследовательские альтернативы и точная подстройка. По желанию, по умолчанию выключено, не для клинического применения."
+ }
}
}
},
@@ -132486,7 +133569,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "%1$@ neu starten? Es trennt sich für etwa 30 Sekunden, während es neu startet, und verbindet sich dann von selbst wieder. Ihre aufgezeichneten Daten werden aufbewahrt."
+ "value": "%1$@ neu starten? Es trennt sich für etwa 30 Sekunden, während es neu startet, und verbindet sich dann von selbst wieder. Deine aufgezeichneten Daten werden aufbewahrt."
}
},
"es": {
@@ -132512,6 +133595,12 @@
"state": "translated",
"value": "Uruchomić ponownie %@? Podczas ponownego uruchamiania rozłącza się na około 30 sekund, po czym samoczynnie łączy się ponownie. Twoje zapisane dane zostaną zachowane."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Перезапустить %@? Он отключится примерно на 30 секунд, пока перезагружается, затем подключится сам. Записанные данные сохраняются."
+ }
}
}
},
@@ -132546,6 +133635,12 @@
"state": "translated",
"value": "Uruchom ponownie pasek…"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Перезапустить браслет…"
+ }
}
}
},
@@ -132554,7 +133649,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Starten Sie dieses Band neu?"
+ "value": "Starte dieses Band neu?"
}
},
"es": {
@@ -132569,6 +133664,12 @@
"value": "Redémarrer cette sangle ?"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Перезапустить этот браслет?"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -132626,6 +133727,12 @@
"state": "translated",
"value": "Uruchomiony ponownie po ponownej kalibracji %@ — nie trzeba go ponownie dotykać."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Перезапущено, когда вы откалибровали заново %@ — повторно нажимать не нужно."
+ }
}
}
},
@@ -134134,6 +135241,12 @@
"state": "translated",
"value": "Przeglądaj każdy punktowany blok budzenia pod kątem rzeczywistych dowodów na wstawanie (rytm chodu, zmiana pozycji ciała), a nie tylko wzrost tętna. Blokada bez ruchu i stabilna postawa – gorąca noc, krótki obrót – ponownie zapada w lekki sen; prawdziwe wstawanie zostaje pozostawione w spokoju. Samoczynnie sprawdza, ile szczegółów ruchu faktycznie zarejestrował Twój pasek, i pozostaje wyłączony w nocy, w której nie można ufać (głównie starsze oprogramowanie WHOOP 4.0). Domyślnie wyłączone; obowiązuje od kolejnych wystawianych nocy."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проверяет каждый оценённый блок бодрствования на реальные признаки подъёма (ритм шагов, смена положения тела), а не только на рост пульса. Блок бодрствования без перемещения и с устойчивой позой — жаркая ночь, короткий переворот — возвращается в лёгкий сон; настоящий подъём остаётся как есть. Сам проверяет, сколько данных о движении действительно записал браслет, и остаётся выключенным в ночь, где их слишком мало, чтобы доверять (в основном старые прошивки WHOOP 4.0). По умолчанию выключено; вступает в силу для следующих размеченных ночей."
+ }
}
}
},
@@ -135308,7 +136421,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Läuft den kontinuierlichen HRV-Stream nur während Ihres ruhigen Stundenfensters (standardmäßig 22:00-07:00 Uhr) aus und halbiert damit die Batteriekosten. Tagesstresswerte werden sparsamer sein. Hinweis: Die kontinuierliche Hintergrund-HRV-Erfassung (einschließlich Nickerchen am Tag) wird außerhalb dieses Fensters angehalten. Für On-Demand-HRV-Messungen am Tag (einschließlich Nickerchen), verwenden Sie die Schaltfläche \"Take an HRV reading\" auf dem Live-Bildschirm."
+ "value": "Läuft den kontinuierlichen HRV-Stream nur während deines ruhigen Stundenfensters (standardmäßig 22:00-07:00 Uhr) aus und halbiert damit die Batteriekosten. Tagesstresswerte werden sparsamer sein. Hinweis: Die kontinuierliche Hintergrund-HRV-Erfassung (einschließlich Nickerchen am Tag) wird außerhalb dieses Fensters angehalten. Für On-Demand-HRV-Messungen am Tag (einschließlich Nickerchen), verwende die Schaltfläche \"Take an HRV reading\" auf dem Live-Bildschirm."
}
},
"es": {
@@ -135334,6 +136447,12 @@
"state": "translated",
"value": "Uruchamia ciągły strumień HRV tylko w godzinach ciszy nocnej (domyślnie 22:00–07:00), co zmniejsza koszt baterii mniej więcej o połowę. Odczyty stresu w ciągu dnia będą rzadsze. Uwaga: ciągłe przechwytywanie HRV w tle (w tym drzemki w ciągu dnia) jest wstrzymywane poza tym oknem. W przypadku odczytów HRV na żądanie w ciągu dnia (w tym drzemek) użyj przycisku „Wykonaj odczyt HRV” na ekranie Na żywo."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Запускает непрерывный поток ВСР только в ваши тихие часы (по умолчанию 22:00–07:00), примерно вдвое снижая расход батареи. Дневные измерения стресса станут реже. Примечание: непрерывный фоновый сбор ВСР (включая дневной сон) вне этого окна приостановлен. Для дневных измерений ВСР по запросу (в том числе во время дневного сна) используйте кнопку «Сделать замер ВСР» на экране «В реальном времени»."
+ }
}
}
},
@@ -139688,6 +140807,12 @@
"state": "translated",
"value": "Wyślij sondę (tylko do odczytu)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Отправить пробу (только чтение)"
+ }
}
}
},
@@ -139722,6 +140847,12 @@
"state": "translated",
"value": "Wysyła GET_BODY_LOCATION_AND_STATUS (0x54) tylko do odczytu i pokazuje pełną nieprzetworzoną odpowiedź paska, dekodując zapis lokalizacji ciała (wersja/lokalizacja/pewność/status) w WHOOP 4.0. Nic nie jest zapisane na pasku i nigdy nie zmienia sposobu wykrywania zużycia ani punktacji."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Отправляет команду только для чтения GET_BODY_LOCATION_AND_STATUS (0x54) и показывает полный сырой ответ браслета, декодируя запись о положении на теле (ревизия / положение / достоверность / статус) на WHOOP 4.0. На браслет ничего не записывается, и это никогда не меняет определение ношения или оценки."
+ }
}
}
},
@@ -141586,6 +142717,12 @@
"value": "Afficher une carte Aujourd'hui vous rappelant de remplir votre journal"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Показывать карточку на «Сегодня» с напоминанием заполнить журнал"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -142391,6 +143528,12 @@
"state": "translated",
"value": "Trening pasków polegający na ciszy podczas treningów."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Подсказки браслета во время тренировок — по умолчанию молча."
+ }
}
}
},
@@ -145029,6 +146172,12 @@
"value": "Stade de sommeil (V2)"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Определение стадий сна (V2)"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -145254,6 +146403,12 @@
"state": "translated",
"value": "Tętno podczas snu w nocy"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пульс во сне в течение ночи"
+ }
}
}
},
@@ -145515,6 +146670,12 @@
"value": "Lentifie le sangle de fond (toutes les 45 min au lieu de 15) tandis que la batterie de votre sangle est basse. Aucune perte de données — la sangle accumule tout, donc synchronise juste les lots dans des tirages plus grands et moins fréquents."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Замедляет фоновую синхронизацию с браслетом (каждые 45 минут вместо 15), пока заряд браслета низкий. Потери данных нет — браслет всё накапливает, поэтому синхронизация просто идёт более крупными и редкими порциями."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -146739,6 +147900,12 @@
"value": "Tuiles carrées avec un graphique de tendance sous la barre."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Более квадратные плитки с графиком тренда под полосой."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -150220,6 +151387,12 @@
"state": "translated",
"value": "Cel Obciążenie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Целевая нагрузка"
+ }
}
}
},
@@ -151624,6 +152797,12 @@
"state": "translated",
"value": "Pasemko"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Серия"
+ }
}
}
},
@@ -154001,7 +155180,135 @@
}
}
},
- "Syncing strap history…": {
+ "Pending sync": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sync ausstehend"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pending sync"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sincronización pendiente"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Synchronisation en attente"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sincronizzazione in sospeso"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Oczekuje na synchronizację"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sincronização pendente"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ожидает синхронизации"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "等待同步"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "等待同步"
+ }
+ }
+ }
+ },
+ "strap history still offloading": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Strap-Verlauf wird noch übertragen"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "strap history still offloading"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "el historial de la pulsera aún se está transfiriendo"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "l'historique du bracelet est encore en cours de transfert"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "la cronologia della fascia è ancora in trasferimento"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "historia paska wciąż jest przesyłana"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "o histórico da bracelete ainda está a ser transferido"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "история браслета ещё передаётся"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "手环历史记录仍在传输"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "手環歷史記錄仍在傳輸"
+ }
+ }
+ }
+ },
+ "Syncing strap history…": {
"localizations": {
"de": {
"stringUnit": {
@@ -155488,6 +156795,12 @@
"state": "translated",
"value": "Kliknij dzień, aby nadrobić zaległości"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Нажмите на день, чтобы наверстать"
+ }
}
}
},
@@ -155554,7 +156867,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Tippen Sie auf eine Bühne, um mit Ihrem 30-Tage-Typ zu vergleichen."
+ "value": "Tippe auf eine Bühne, um mit deinem 30-Tage-Typ zu vergleichen."
}
},
"es": {
@@ -155580,6 +156893,12 @@
"state": "translated",
"value": "Kliknij etap, aby porównać go z typowym 30-dniowym."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Нажмите на стадию, чтобы сравнить с вашим типичным значением за 30 дней."
+ }
}
}
},
@@ -155989,6 +157308,64 @@
}
}
},
+ "Skin temperature display": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Hauttemperatur-Anzeige"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Visualización de temperatura cutánea"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Affichage de la température cutanée"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Visualizzazione temperatura cutanea"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Wyświetlanie temperatury skóry"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Apresentação da temperatura da pele"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Отображение температуры кожи"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "皮肤温度显示"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "皮膚溫度顯示"
+ }
+ }
+ }
+ },
"Temperature unit": {
"localizations": {
"de": {
@@ -156592,7 +157969,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Deshalb beginnt eine Watch-Only-Energie mit \"Kalibrieren\". NOOP benötigt etwa sieben Nächte Ihrer HRV, um zu erfahren, wie normal für Sie aussieht. Bis es sie hat, hält es die Punktzahl zurück, anstatt zu erraten. Sobald die Basislinie festgelegt ist, erscheint Ihre Energie mit ihrem Vertrauen auf der gleichen 0-100-Skala wie ein Gurt."
+ "value": "Deshalb beginnt eine Watch-Only-Energie mit \"Kalibrieren\". NOOP benötigt etwa sieben Nächte deiner HRV, um zu erfahren, wie normal für dich aussieht. Bis es sie hat, hält es die Punktzahl zurück, anstatt zu erraten. Sobald die Basislinie festgelegt ist, erscheint deine Energie mit ihrem Vertrauen auf der gleichen 0-100-Skala wie ein Gurt."
}
},
"es": {
@@ -156618,6 +157995,12 @@
"state": "translated",
"value": "Dlatego Energia przeznaczony tylko do oglądania rozpoczyna się od „Kalibracji”. NOOP potrzebuje około siedmiu nocy Twojego HRV, aby dowiedzieć się, jak wygląda normalność w Twoim przypadku. Dopóki ich nie ma, raczej wstrzymuje wynik niż zgaduje. Po ustawieniu linii bazowej Twój Energia pojawi się pewnie, w tej samej skali 0-100, co pasek."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Поэтому Заряд, построенный только по часам, сначала показывает «Калибровку». NOOP нужно около семи ночей вашей ВСР, чтобы понять, что для вас нормально. Пока их нет, он не показывает оценку, вместо того чтобы гадать. Как только норма определена, ваш Заряд появляется вместе с его достоверностью, по той же шкале 0–100, что и у браслета."
+ }
}
}
},
@@ -156742,7 +158125,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Der WHOOP 4.0-Reboot-Frame wird nicht bestätigt - ein normaler Neustart wird ignoriert (# 235). Senden sie jedem kandidaten und beobachten sie beide das straplog und das strap selbst. \"Keine Trennung innerhalb von 12s\" bedeutet, dass das Band den Rahmen ignoriert hat. Eine \"Link Drop\" -Linie bedeutet, dass der Rahmen den Riemen erreicht hat - aber ein Drop Link allein ist kein Neustart: Ein echter Neustart schaltet auch das Sensorlicht des Riemens für ein paar Sekunden aus, also wenn das Licht anblieb, war es nur eine Drop-Verbindung, kein Neustart. Zerstörungsfrei - Ihre Daten werden gespeichert. Bitte teilen Sie das Protokoll, damit wir den echten Rahmen anheften können."
+ "value": "Der WHOOP 4.0-Reboot-Frame wird nicht bestätigt - ein normaler Neustart wird ignoriert (# 235). Sende sie an jeden Kandidaten und beobachte dabei sowohl das Straplog als auch den Strap selbst. \"Keine Trennung innerhalb von 12s\" bedeutet, dass das Band den Rahmen ignoriert hat. Eine \"Link Drop\" -Linie bedeutet, dass der Rahmen den Riemen erreicht hat - aber ein Drop Link allein ist kein Neustart: Ein echter Neustart schaltet auch das Sensorlicht des Riemens für ein paar Sekunden aus, also wenn das Licht anblieb, war es nur eine Drop-Verbindung, kein Neustart. Zerstörungsfrei - deine Daten werden gespeichert. Bitte teile das Protokoll, damit wir den echten Rahmen anheften können."
}
},
"es": {
@@ -156768,6 +158151,12 @@
"state": "translated",
"value": "Ramka ponownego uruchomienia WHOOP 4.0 nie została potwierdzona — normalne ponowne uruchomienie jest ignorowane (#235). Wyślij każdego kandydata i obejrzyj ZARÓWNO dziennik paska, jak i sam pasek. „brak rozłączenia w ciągu 12 s” oznacza, że pasek zignorował ramę. Linia „opuszczone ogniwo” oznacza, że rama dotarła do paska — ale samo upuszczone ogniwo nie oznacza ponownego uruchomienia: prawdziwe ponowne uruchomienie powoduje również wyłączenie na kilka sekund lampki czujnika paska, zatem jeśli lampka pozostanie włączona, oznacza to po prostu zerwanie połączenia, a nie ponowne uruchomienie. Nieniszczące — Twoje dane są przechowywane. Udostępnij log, abyśmy mogli przypiąć prawdziwą ramkę."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Кадр перезагрузки WHOOP 4.0 не подтверждён — обычный перезапуск игнорируется (#235). Отправляйте каждый вариант и следите И за журналом браслета, И за самим браслетом. «нет отключения за 12 с» означает, что браслет проигнорировал кадр. Строка «связь потеряна» означает, что кадр дошёл до браслета, — но одна лишь потеря связи ещё не перезагрузка: при настоящей перезагрузке датчик браслета также гаснет на несколько секунд, поэтому если подсветка не погасла, это был просто обрыв соединения, а не перезагрузка. Ничего не разрушается — ваши данные сохраняются. Пожалуйста, поделитесь журналом, чтобы мы могли определить настоящий кадр."
+ }
}
}
},
@@ -157034,6 +158423,12 @@
"state": "translated",
"value": "Zmiana tętna pod koniec ćwiczeń. Wartości dodatnie oznaczają spadek tętna; myślnik oznacza, że w tej minucie pasek nie zarejestrował wystarczającej ilości danych."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Изменение относительно вашего пульса в конце нагрузки. Положительные значения означают, что пульс снизился; прочерк означает, что браслет не записал достаточно данных около этой минуты."
+ }
}
}
},
@@ -157158,7 +158553,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Die Linie ist der 0-3 Proxy jeder Wachstunde, der heute gegen Ihre eigenen ruhigen Stunden erzielt wurde. Die Bar unten teilt Ihren Tag in ruhige, moderate und hohe Stresszeit."
+ "value": "Die Linie ist der 0-3 Proxy jeder Wachstunde, der heute gegen deine eigenen ruhigen Stunden erzielt wurde. Die Bar unten teilt deinen Tag in ruhige, moderate und hohe Stresszeit."
}
},
"es": {
@@ -157184,6 +158579,12 @@
"state": "translated",
"value": "Linia to przybliżony wskaźnik 0-3 każdej godziny czuwania, porównany z Twoimi dzisiejszymi spokojnymi godzinami. Poniższy pasek dzieli Twój dzień na czas spokoju, umiarkowanego i dużego stresu."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Линия — это показатель 0–3 за каждый час бодрствования, оценённый относительно ваших собственных спокойных часов сегодня. Полоса ниже делит ваш день на время спокойного, умеренного и высокого стресса."
+ }
}
}
},
@@ -157662,7 +159063,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Der Riemen meldet eine andere Zeit als NOOP sendet, so dass sein Firmware-Alarm nicht zu Ihrer Weckzeit feuert - normalerweise ein Riemen, dessen Uhr oder Alarm zurückgesetzt wurde. Setzen Sie den Riemen in der offiziellen WHOOP-App zurück (oder laden Sie ihn vollständig auf und verbinden Sie ihn wieder), und halten Sie den Uhralarm Ihres Telefons solange wach, bis es dauert."
+ "value": "Der Riemen meldet eine andere Zeit als NOOP sendet, so dass sein Firmware-Alarm nicht zu deiner Weckzeit feuert - normalerweise ein Riemen, dessen Uhr oder Alarm zurückgesetzt wurde. Setze den Riemen in der offiziellen WHOOP-App zurück (oder lade ihn vollständig auf und verbinde ihn wieder), und halte den Uhralarm deines Telefons solange wach, bis es dauert."
}
},
"es": {
@@ -157688,6 +159089,12 @@
"state": "translated",
"value": "Pasek zgłasza inny czas niż wysyła NOOP, więc alarm oprogramowania sprzętowego nie włączy się o godzinie pobudki — zwykle jest to pasek, którego zegar lub alarm został zresetowany. Zresetuj pasek w oficjalnej aplikacji WHOOP (lub naładuj go do pełna i podłącz ponownie), a budzik w telefonie będzie włączał się do momentu, aż to nastąpi."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Браслет продолжает сообщать время, отличное от отправленного NOOP, поэтому его встроенный будильник не сработает в час вашего пробуждения — обычно это браслет, у которого сбросились часы или будильник. Сбросьте браслет в официальном приложении WHOOP (или полностью зарядите его и подключите заново), а до тех пор используйте будильник в «Часах» телефона."
+ }
}
}
},
@@ -158573,7 +159980,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Auch diese Sicherungen sind unverschlüsselt. Wenn dieser Ordner mit Drive, Dropbox oder iCloud synchronisiert wird, landet die lesbare Datei auch dort — zeigen Sie nur auf einen Dienst, dem Sie vertrauen."
+ "value": "Auch diese Sicherungen sind unverschlüsselt. Wenn dieser Ordner mit Drive, Dropbox oder iCloud synchronisiert wird, landet die lesbare Datei auch dort — zeige nur auf einen Dienst, dem du vertraust."
}
},
"en": {
@@ -159296,7 +160703,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Das ist ein einfaches, unverschlüsseltes Archiv — wer die Datei bekommt, kann sie mit jedem Zip-Programm öffnen. Bewahren Sie sie an einem Ort auf, dem Sie vertrauen."
+ "value": "Das ist ein einfaches, unverschlüsseltes Archiv — wer die Datei bekommt, kann sie mit jedem Zip-Programm öffnen. Bewahre sie an einem Ort auf, dem du vertraust."
}
},
"en": {
@@ -159910,6 +161317,12 @@
"value": "Cela déplace la nuit à une heure sans données enregistrées. Les étapes ne peuvent pas être dérivées là, donc il peut apparaître comme vide jusqu'à ce que les données le couvrent."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Это перенесёт ночь на время, где нет записанных данных. Стадии там вывести нельзя, поэтому она может отображаться пустой, пока данные её не покроют."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -163902,6 +165315,12 @@
"value": "Tableaux de tendances"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Графики трендов"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -164011,6 +165430,12 @@
"state": "translated",
"value": "Okno trendu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Окно тренда"
+ }
}
}
},
@@ -164855,7 +166280,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Schalten Sie Benachrichtigungen für NOOP in den Einstellungen ein, um Ihre Winddown-Erinnerung zu erhalten."
+ "value": "Schalte Benachrichtigungen für NOOP in den Einstellungen ein, um deine Winddown-Erinnerung zu erhalten."
}
},
"es": {
@@ -164881,6 +166306,12 @@
"state": "translated",
"value": "Włącz powiadomienia dla NOOP w Ustawieniach, aby otrzymać przypomnienie o odpoczynku."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Включите уведомления для NOOP в Настройках, чтобы получать напоминание о подготовке ко сну."
+ }
}
}
},
@@ -164973,6 +166404,12 @@
"state": "translated",
"value": "Dwie niezależne tabele protokołów nie zgadzają się co do kodu operacji o rozszerzonej baterii (98 vs 87). Spowoduje to wysłanie wybranego numeru 98 w trybie tylko do odczytu i wyświetlenie pełnej nieprzetworzonej odpowiedzi paska. Ładunek przypominający baterię potwierdza 98 w oprogramowaniu sprzętowym; krótki odcinek oznacza, że pozostaje niejednoznaczny. Na pasku nic nie jest zapisane."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Две независимые таблицы протокола расходятся в коде расширенных данных о батарее (98 против 87). Здесь отправляется проверенная команда только для чтения 98 и показывается полный сырой ответ браслета. Ответ в формате батареи подтверждает 98 на вашей прошивке; короткая заглушка означает, что вопрос остаётся открытым. На браслет ничего не записывается."
+ }
}
}
},
@@ -165228,6 +166665,12 @@
"value": "Annuler"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Отменить"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -168030,6 +169473,12 @@
"value": "Sonde de redémarrage WHOOP 4.0"
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Проба перезагрузки WHOOP 4.0"
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -169828,6 +171277,12 @@
"state": "translated",
"value": "Czekam na odpowiedź paska…"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ожидание ответа браслета…"
+ }
}
}
},
@@ -170944,7 +172399,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Wir vergleichen die heutige Ruheherzfrequenz und HRV mit Ihrer eigenen 30-Tage-Baseline. Eine höher als üblich ruhende HR und eine niedriger als üblich HRV schieben beide die Punktzahl nach oben, klassische Zeichen, dass der Körper aktiviert wird. Die kombinierte Schicht wird auf eine 0-3 Skala abgebildet: 0 ist ruhig, 1,5 sitzt an der Basislinie, 3 ist stark aktiviert."
+ "value": "Wir vergleichen die heutige Ruheherzfrequenz und HRV mit deiner eigenen 30-Tage-Baseline. Eine höher als üblich ruhende HR und eine niedriger als üblich HRV schieben beide die Punktzahl nach oben, klassische Zeichen, dass der Körper aktiviert wird. Die kombinierte Schicht wird auf eine 0-3 Skala abgebildet: 0 ist ruhig, 1,5 sitzt an der Basislinie, 3 ist stark aktiviert."
}
},
"es": {
@@ -170970,6 +172425,12 @@
"state": "translated",
"value": "Porównujemy dzisiejsze tętno spoczynkowe i HRV z Twoją własną wartością bazową z 30 dni. Wyższe niż zwykle tętno spoczynkowe i niższe niż zwykle HRV podnoszą wynik, co jest klasycznym sygnałem, że organizm jest pobudzony. Połączone przesunięcie jest odwzorowywane na skali 0-3: 0 oznacza spokój, 1,5 znajduje się na poziomie podstawowym, 3 jest wysoce aktywowany."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Мы сравниваем сегодняшние пульс в покое и ВСР с вашей собственной 30-дневной нормой. Более высокий, чем обычно, пульс в покое и более низкая, чем обычно, ВСР — оба повышают оценку: классические признаки активации организма. Совокупный сдвиг отображается на шкалу 0–3: 0 — спокойствие, 1,5 — ваша норма, 3 — сильная активация."
+ }
}
}
},
@@ -173682,7 +175143,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Wenn NOOP die Herzfrequenz aus der optischen Wellenform WHOOP 5/MG v26 rekonstruiert (die Sekunden, in denen das Band keine HR gespeichert hat), verfeinern Sie den Autokorrelationspeak mit einem parabolischen Sublag-Fit, so dass die Schätzung nicht auf etwa 16 bpm-Schritte in der Nähe einer hohen HR quantifiziert wird. Es füllt nur Sekunden, die das Band nie gemeldet hat; es überschreibt niemals eine gespeicherte HR. 5/MG, standardmäßig ausgeschaltet."
+ "value": "Wenn NOOP die Herzfrequenz aus der optischen Wellenform WHOOP 5/MG v26 rekonstruiert (die Sekunden, in denen das Band keine HR gespeichert hat), verfeinere den Autokorrelationspeak mit einem parabolischen Sublag-Fit, so dass die Schätzung nicht auf etwa 16 bpm-Schritte in der Nähe einer hohen HR quantifiziert wird. Es füllt nur Sekunden, die das Band nie gemeldet hat; es überschreibt niemals eine gespeicherte HR. 5/MG, standardmäßig ausgeschaltet."
}
},
"es": {
@@ -173708,6 +175169,12 @@
"state": "translated",
"value": "Kiedy NOOP rekonstruuje tętno na podstawie fali optycznej WHOOP 5/MG v26 (sekundy, w których pasek nie zapisuje HR), doprecyzuj pik autokorelacji za pomocą parabolicznego dopasowania z sub-opóźnieniem, aby oszacowanie nie było kwantyzowane do około 16 kroków bpm w pobliżu wysokiego HR. Wypełnia tylko sekundy, o których pasek nigdy nie raportował; nigdy nie zastępuje zapisanego HR. Tylko 5/MG, domyślnie wyłączone."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Когда NOOP восстанавливает пульс по оптическому сигналу v26 WHOOP 5/MG (за секунды, для которых браслет не сохранил пульс), уточнять пик автокорреляции параболической подгонкой внутри задержки, чтобы оценка не квантовалась шагами примерно по 16 уд/мин при высоком пульсе. Заполняются только секунды, которые браслет никогда не передавал; сохранённый пульс никогда не перезаписывается. Только 5/MG, по умолчанию выключено."
+ }
}
}
},
@@ -174562,6 +176029,12 @@
"value": "Quand la batterie de votre bracelet est basse, arrêtez le flux de VFC en arrière-plan, ce qui sollicite le plus votre bracelet. Un écran Live affiche toujours la fréquence cardiaque, et il recommence automatiquement une fois le bracelet chargé."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Пока заряд браслета низкий, останавливать постоянный фоновый поток ВСР — самый большой непрерывный расход для браслета. Экран «В реальном времени» по-прежнему показывает пульс, а поток включится сам, как только браслет зарядится."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -174587,7 +176060,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Die ganze Nacht ist das Standardmaß von NOOP; Deep Sleep Pools HRV nur über Slow Wave Sleep, lesen niedriger und passend zu WHOOP. Durch das Umschalten werden Ihre letzten Nächte über das neue Fenster neu bewertet und sofort wirksam, sobald Sie ein paar Nächte Daten haben."
+ "value": "Die ganze Nacht ist das Standardmaß von NOOP; Deep Sleep Pools HRV nur über Slow Wave Sleep, lesen niedriger und passend zu WHOOP. Durch das Umschalten werden deine letzten Nächte über das neue Fenster neu bewertet und sofort wirksam, sobald du ein paar Nächte Daten hast."
}
},
"es": {
@@ -174602,6 +176075,12 @@
"value": "Toute la nuit est la mesure par défaut de NOOP; Le sommeil profond met en commun la VFC sur le sommeil à ondes lentes uniquement, en lisant plus bas et correspondant à WHOOP. Un changement recalcule vos dernières nuits sur la nouvelle fenêtre et prend effet dès que vous avez quelques nuits de données."
}
},
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Вся ночь — мера NOOP по умолчанию; «Глубокий сон» собирает ВСР только по медленноволновому сну, даёт более низкие значения и совпадает с WHOOP. Переключение пересчитает ваши недавние ночи по новому окну и вступит в силу сразу, как только накопится несколько ночей данных."
+ }
+ },
"zh-Hans": {
"stringUnit": {
"state": "translated",
@@ -178038,6 +179517,12 @@
"state": "translated",
"value": "Osiągnąłeś dzisiejsze optymalne docelowe odkształcenie %lld. Niezła robota – zasłużyłeś na powrót do zdrowia."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Вы достигли сегодняшней цели по нагрузке — %lld. Отличная работа, ваше восстановление это заслужило."
+ }
}
}
},
@@ -180663,6 +182148,12 @@
"state": "translated",
"value": "Twój pasek nie akceptuje alarmu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ваш браслет не принимает будильник"
+ }
}
}
},
@@ -181185,6 +182676,12 @@
"state": "translated",
"value": "Strefa %lld: %lld-%lld bpm (%lld-%lld%% max HR)"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Зона %lld: %lld–%lld уд/мин (%lld–%lld%% от макс. пульса)"
+ }
}
}
},
@@ -183628,6 +185125,12 @@
"state": "translated",
"value": "dzień z rzędu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "день подряд"
+ }
}
}
},
@@ -183668,6 +185171,12 @@
"state": "translated",
"value": "dni z rzędu"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "дней подряд"
+ }
}
}
},
@@ -191947,6 +193456,12 @@
"state": "translated",
"value": "zazwyczaj %@"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "обычно %@"
+ }
}
}
},
@@ -192451,6 +193966,12 @@
"state": "translated",
"value": "Historia v%lld"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "история v%lld"
+ }
}
}
},
@@ -194950,6 +196471,262 @@
}
}
},
+ "12-hour": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12 Stunden"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12-hour"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12 horas"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12 heures"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12 ore"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12-godzinny"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12 horas"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12-часовой"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12 小时制"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "12 小時制"
+ }
+ }
+ }
+ },
+ "24-hour": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24 Stunden"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24-hour"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24 horas"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24 heures"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24 ore"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24-godzinny"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24 horas"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24-часовой"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24 小时制"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "24 小時制"
+ }
+ }
+ }
+ },
+ "Clock": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Uhr"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Clock"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reloj"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Horloge"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Orologio"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zegar"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Relógio"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Часы"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "时钟"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "時鐘"
+ }
+ }
+ }
+ },
+ "Hide bar when scrolling": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Leiste beim Scrollen ausblenden"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Hide bar when scrolling"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ocultar la barra al desplazarse"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Masquer la barre pendant le défilement"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nascondi la barra durante lo scorrimento"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ukryj pasek podczas przewijania"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ocultar a barra ao deslocar"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Скрывать панель при прокрутке"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "滚动时隐藏底部栏"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "捲動時隱藏底部列"
+ }
+ }
+ }
+ },
"System default": {
"localizations": {
"de": {
@@ -195426,6 +197203,122 @@
}
}
},
+ "Only %lld%% of this night's window has stage data. The stage totals cover only that part of the night.": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nur %lld%% des Zeitfensters dieser Nacht enthält Schlafphasendaten. Die Phasenwerte decken nur diesen Teil der Nacht ab."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Solo el %lld%% de la ventana de esta noche tiene datos de fases. Los totales de fases cubren solo esa parte de la noche."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Seuls %lld%% de la fenêtre de cette nuit comportent des données de phases. Les totaux de phases ne couvrent que cette partie de la nuit."
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Solo il %lld%% della finestra di questa notte ha dati sulle fasi. I totali delle fasi coprono solo quella parte della notte."
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Apenas %lld%% da janela desta noite tem dados de fases. Os totais de fases cobrem apenas essa parte da noite."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Только %lld%% окна этой ночи содержит данные о фазах. Итоги по фазам охватывают только эту часть ночи."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "这一夜的时间窗口中只有 %lld%% 有睡眠阶段数据。阶段合计仅涵盖该部分时间。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "這一夜的時間視窗中只有 %lld%% 有睡眠階段資料。階段合計僅涵蓋該部分時間。"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tylko %lld%% okna tej nocy ma dane o fazach. Sumy faz obejmują tylko tę część nocy."
+ }
+ }
+ }
+ },
+ "Partly recorded": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Teilweise aufgezeichnet"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Registro parcial"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Enregistrement partiel"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Registrazione parziale"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Registo parcial"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Записано частично"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "部分记录"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "部分記錄"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Częściowo zarejestrowane"
+ }
+ }
+ }
+ },
"Customize": {
"localizations": {
"de": {
@@ -207265,6 +209158,12 @@
"state": "translated",
"value": "Obciążenie treningowe"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Тренировочная нагрузка"
+ }
}
}
},
@@ -207299,6 +209198,12 @@
"state": "translated",
"value": "CTL · Kondycja"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "CTL · Форма"
+ }
}
}
},
@@ -207333,6 +209238,12 @@
"state": "translated",
"value": "ATL · Zmęczenie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ATL · Усталость"
+ }
}
}
},
@@ -207367,6 +209278,12 @@
"state": "translated",
"value": "Kondycja 42-dniowa vs zmęczenie 7-dniowe"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "42-дневная форма против 7-дневной усталости"
+ }
}
}
},
@@ -207401,6 +209318,12 @@
"state": "translated",
"value": "Budowanie — %1$lld z %2$lld dni"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Формируется — %lld из %lld дней"
+ }
}
}
},
@@ -207435,6 +209358,12 @@
"state": "translated",
"value": "Obciążenie przewlekłe vs ostre"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Хроническая против острой нагрузки"
+ }
}
}
},
@@ -207469,6 +209398,12 @@
"state": "translated",
"value": "Wymaga %1$lld+ kolejnych dni Wysiłku, aby zacząć. Na razie %2$lld."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Нужно %lld+ подряд дней с Усилием, чтобы начать. Пока %lld."
+ }
}
}
},
@@ -207503,6 +209438,12 @@
"state": "translated",
"value": "Forma"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Форма"
+ }
}
}
},
@@ -207537,6 +209478,12 @@
"state": "translated",
"value": "Obciążenie treningowe: przewlekłe vs ostre"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Тренировочная нагрузка: хроническая против острой"
+ }
}
}
},
@@ -207571,6 +209518,12 @@
"state": "translated",
"value": "Rzadsze odświeżanie"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Редкое обновление"
+ }
}
}
},
@@ -207605,6 +209558,12 @@
"state": "translated",
"value": "Synchronizuje w tle co godzinę zamiast co 15 minut, niezależnie od poziomu naładowania opaski — mniej ponownych połączeń to największa oszczędność w WHOOP 4.0. Nic nie ginie: opaska zapisuje wszystko i przekazuje w większych paczkach. Ręczna synchronizacja nadal działa od razu, a tętno na żywo pozostaje bez zmian."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Синхронизировать в фоне раз в час вместо каждых 15 минут, независимо от заряда браслета — меньше переподключений даёт наибольшую экономию на WHOOP 4.0. Ничего не теряется: браслет всё накапливает и отдаёт большими порциями. Синхронизация жестом по-прежнему работает сразу, а живой пульс не затрагивается."
+ }
}
}
},
@@ -207639,6 +209598,12 @@
"state": "translated",
"value": "Oszczędzanie energii"
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Энергосбережение"
+ }
}
}
},
@@ -207673,6 +209638,12 @@
"state": "translated",
"value": "Odciąż opaskę, gdy jej bateria się kończy."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Снизьте нагрузку на браслет, когда его заряд на исходе."
+ }
}
}
},
@@ -207707,6 +209678,12 @@
"state": "translated",
"value": "Opaska nadal zapisuje dane samodzielnie, więc nic nie ginie — NOOP po prostu rzadziej się z nią komunikuje, aby wystarczyła do naładowania."
}
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Браслет продолжает накапливать данные сам, поэтому ничего не теряется — NOOP просто реже с ним общается, чтобы тот дожил до зарядки."
+ }
}
}
},
@@ -207834,7 +209811,428 @@
} },
"A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more.": { "localizations": {
"de": { "stringUnit": { "state": "translated", "value": "Eine kardiovaskuläre Last aus der Zeit in den Herzfrequenzzonen (Edwards-TRIMP): Jede Zone wird gewichtet, sodass härtere Zonen mehr zählen, aufsummiert zu einer Tageszahl. In den Einstellungen gibt es eine exponentielle Alternative (Banister), die kurze, harte Belastungen stärker anrechnet." } }, "en": { "stringUnit": { "state": "translated", "value": "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more." } }, "es": { "stringUnit": { "state": "translated", "value": "Una carga cardiovascular a partir del tiempo en las zonas de frecuencia cardíaca (TRIMP de Edwards): cada zona se pondera para que las más duras cuenten más, sumado en una sola cifra diaria. En Ajustes hay una alternativa exponencial (Banister) que valora más los esfuerzos cortos e intensos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Une charge cardiovasculaire issue du temps passé dans les zones de fréquence cardiaque (TRIMP d’Edwards) : chaque zone est pondérée pour que les plus intenses comptent davantage, additionnée en un seul chiffre journalier. Les Réglages proposent une alternative exponentielle (Banister) qui valorise davantage les efforts courts et intenses." } }, "it": { "stringUnit": { "state": "translated", "value": "Un carico cardiovascolare dato dal tempo nelle zone di frequenza cardiaca (TRIMP di Edwards): ogni zona è ponderata in modo che quelle più dure contino di più, sommato in un unico dato giornaliero. Nelle Impostazioni c’è un’alternativa esponenziale (Banister) che premia di più gli sforzi brevi e intensi." } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie układu krążenia wynikające z czasu w strefach tętna (TRIMP Edwardsa): każda strefa jest ważona tak, aby trudniejsze liczyły się bardziej, zsumowane w jedną wartość dzienną. W Ustawieniach jest alternatywa wykładnicza (Banister), która mocniej docenia krótkie, intensywne wysiłki." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Uma carga cardiovascular a partir do tempo nas zonas de frequência cardíaca (TRIMP de Edwards): cada zona é ponderada para que as mais difíceis contem mais, resumido num valor diário. Nas Definições existe uma alternativa exponencial (Banister) que valoriza mais os esforços curtos e intensos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Сердечно-сосудистая нагрузка по времени в пульсовых зонах (TRIMP Эдвардса): каждая зона взвешивается так, что более тяжёлые учитываются сильнее, и всё суммируется в одно дневное значение. В настройках есть экспоненциальная альтернатива (Banister), которая сильнее засчитывает короткие интенсивные усилия." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "根据你在各心率区间的时长得出的心血管负荷(Edwards TRIMP):区间越高权重越大,汇总为一个每日数字。设置中提供指数式替代方案(Banister),会更看重短时高强度的努力。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "根據你在各心率區間停留時間得出的心血管負荷(Edwards TRIMP):區間越高權重越大,加總成每日一個數字。設定中提供指數式替代方案(Banister),會更看重短時高強度的努力。" } }
+ } },
+ "WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG unterstützt Live-Daten und Bandverlauf. Protokollwerkzeuge sind separat im Testcenter."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG admite datos en directo e historial. Las herramientas de protocolo están aparte en el Centro de pruebas."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG gère les données en direct et l’historique. Les outils de protocole sont séparés dans le Centre de test."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG suporta dados em direto e histórico. As ferramentas de protocolo estão separadas no Centro de testes."}},"it":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG supporta i dati in tempo reale e la cronologia della fascia. Gli strumenti di ricerca sul protocollo sono separati nel Centro test."}},"pl":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG obsługuje dane na żywo i historię paska. Narzędzia badawcze protokołu są dostępne osobno w Centrum Testowym."}},"ru":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG поддерживает данные в реальном времени и историю браслета. Инструменты исследования протокола вынесены в Тестовый центр."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG 支持实时数据和手环历史。协议研究工具单独位于测试中心。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP 5.0/MG 支援即時資料與手環歷史。協定研究工具另置於測試中心。"}}}},
+ "Delete this session?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Diese Session löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar esta sesión?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer cette session ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar esta sessão?"}},"it":{"stringUnit":{"state":"translated","value":"Eliminare questa sessione?"}},"pl":{"stringUnit":{"state":"translated","value":"Usunąć tę sesję?"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить эту сессию?"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"要删除此会话吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"要刪除此工作階段嗎?"}}}},
+ "The session %@ and its captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Session %@ und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"La sesión %@ y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"La session %@ et ses données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A sessão %@ e os dados serão eliminados permanentemente."}},"it":{"stringUnit":{"state":"translated","value":"La sessione %@ e i suoi dati grezzi verranno eliminati definitivamente."}},"pl":{"stringUnit":{"state":"translated","value":"Sesja %@ i jej surowe dane zostaną trwale usunięte."}},"ru":{"stringUnit":{"state":"translated","value":"Сессия %@ и её сырые данные будут удалены безвозвратно."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"会话 %@ 及其采集的原始数据将被永久删除。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"工作階段 %@ 及其擷取的原始資料將被永久刪除。"}}}},
+ "Delete all sessions?": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen?"}},"es":{"stringUnit":{"state":"translated","value":"¿Eliminar todas las sesiones?"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions ?"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões?"}},"it":{"stringUnit":{"state":"translated","value":"Eliminare tutte le sessioni?"}},"pl":{"stringUnit":{"state":"translated","value":"Usunąć wszystkie sesje?"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить все сессии?"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"要删除所有会话吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"要刪除所有工作階段嗎?"}}}},
+ "All %lld recorded sessions and their captured raw data will be deleted permanently.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle %lld Sessions und ihre Rohdaten werden dauerhaft gelöscht."}},"es":{"stringUnit":{"state":"translated","value":"Las %lld sesiones y sus datos se eliminarán permanentemente."}},"fr":{"stringUnit":{"state":"translated","value":"Les %lld sessions et leurs données seront définitivement supprimées."}},"pt-PT":{"stringUnit":{"state":"translated","value":"As %lld sessões e os dados serão eliminados permanentemente."}},"it":{"stringUnit":{"state":"translated","value":"Tutte le %lld sessioni registrate e i loro dati grezzi verranno eliminati definitivamente."}},"pl":{"stringUnit":{"state":"translated","value":"Wszystkie zarejestrowane sesje (%lld) i ich surowe dane zostaną trwale usunięte."}},"ru":{"stringUnit":{"state":"translated","value":"Все записанные сессии (%lld) и их сырые данные будут удалены безвозвратно."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"全部 %lld 个已记录会话及其采集的原始数据将被永久删除。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"全部 %lld 個已記錄工作階段及其擷取的原始資料將被永久刪除。"}}}},
+ "Couldn't export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht exportiert werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo exportar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible d’exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível exportar a sessão"}},"it":{"stringUnit":{"state":"translated","value":"Impossibile esportare la sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Nie można wyeksportować sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось экспортировать сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法导出会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法匯出工作階段"}}}},
+ "Unknown error": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Unbekannter Fehler"}},"es":{"stringUnit":{"state":"translated","value":"Error desconocido"}},"fr":{"stringUnit":{"state":"translated","value":"Erreur inconnue"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Erro desconhecido"}},"it":{"stringUnit":{"state":"translated","value":"Errore sconosciuto"}},"pl":{"stringUnit":{"state":"translated","value":"Nieznany błąd"}},"ru":{"stringUnit":{"state":"translated","value":"Неизвестная ошибка"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"未知错误"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"未知錯誤"}}}},
+ "Capture coverage": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abdeckung der Aufnahme"}},"es":{"stringUnit":{"state":"translated","value":"Cobertura de la captura"}},"fr":{"stringUnit":{"state":"translated","value":"Couverture de la capture"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cobertura da captura"}},"it":{"stringUnit":{"state":"translated","value":"Copertura dell'acquisizione"}},"pl":{"stringUnit":{"state":"translated","value":"Pokrycie nagrania"}},"ru":{"stringUnit":{"state":"translated","value":"Покрытие записи"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"采集覆盖率"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"擷取涵蓋率"}}}},
+ "Band: connected%@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: verbunden%@"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: conectada%@"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : connecté%@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: ligada%@"}},"it":{"stringUnit":{"state":"translated","value":"Fascia: collegata%@"}},"pl":{"stringUnit":{"state":"translated","value":"Pasek: połączono%@"}},"ru":{"stringUnit":{"state":"translated","value":"Браслет: подключено%@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环:已连接%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環:已連接%@"}}}},
+ "Band: disconnected": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Band: getrennt"}},"es":{"stringUnit":{"state":"translated","value":"Pulsera: desconectada"}},"fr":{"stringUnit":{"state":"translated","value":"Bracelet : déconnecté"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Bracelete: desligada"}},"it":{"stringUnit":{"state":"translated","value":"Fascia: disconnessa"}},"pl":{"stringUnit":{"state":"translated","value":"Pasek: rozłączono"}},"ru":{"stringUnit":{"state":"translated","value":"Браслет: отключено"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环:已断开"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環:已中斷連接"}}}},
+ "History sync: running (%lld chunks)": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung läuft (%lld Blöcke)"}},"es":{"stringUnit":{"state":"translated","value":"Sincronizando historial (%lld bloques)"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation de l’historique (%lld blocs)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização do histórico (%lld blocos)"}},"it":{"stringUnit":{"state":"translated","value":"Sincronizzazione cronologia: in corso (%lld blocchi)"}},"pl":{"stringUnit":{"state":"translated","value":"Synchronizacja historii: w toku (%lld fragmentów)"}},"ru":{"stringUnit":{"state":"translated","value":"Синхронизация истории: выполняется (%lld фрагментов)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史同步:进行中(%lld 个数据块)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史同步:進行中(%lld 個資料塊)"}}}},
+ "History sync: idle": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verlaufssynchronisierung inaktiv"}},"es":{"stringUnit":{"state":"translated","value":"Sincronización inactiva"}},"fr":{"stringUnit":{"state":"translated","value":"Synchronisation inactive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sincronização inativa"}},"it":{"stringUnit":{"state":"translated","value":"Sincronizzazione cronologia: inattiva"}},"pl":{"stringUnit":{"state":"translated","value":"Synchronizacja historii: bezczynna"}},"ru":{"stringUnit":{"state":"translated","value":"Синхронизация истории: простой"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史同步:空闲"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史同步:閒置"}}}},
+ "Realtime IMU: session active since %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Echtzeit-IMU: Session aktiv seit %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU en tiempo real: sesión activa desde %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU en temps réel : session active depuis %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU em tempo real: sessão ativa desde %@"}},"it":{"stringUnit":{"state":"translated","value":"IMU in tempo reale: sessione attiva da %@"}},"pl":{"stringUnit":{"state":"translated","value":"IMU w czasie rzeczywistym: sesja aktywna od %@"}},"ru":{"stringUnit":{"state":"translated","value":"IMU в реальном времени: сессия активна с %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"实时 IMU:会话自 %@ 起活动"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"即時 IMU:工作階段自 %@ 起進行中"}}}},
+ "Historical export window": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Vergangenes Exportzeitfenster"}},"es":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportación"}},"fr":{"stringUnit":{"state":"translated","value":"Plage d’export historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Intervalo histórico de exportação"}},"it":{"stringUnit":{"state":"translated","value":"Finestra di esportazione storica"}},"pl":{"stringUnit":{"state":"translated","value":"Okno eksportu historycznego"}},"ru":{"stringUnit":{"state":"translated","value":"Окно исторического экспорта"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史导出时间窗"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史匯出時間範圍"}}}},
+ "Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Erstellt eine Session aus dem synchronisierten Verlauf. Noch gepufferte 100-Hz-Daten werden einbezogen."}},"es":{"stringUnit":{"state":"translated","value":"Crea una sesión desde el historial. Incluye los datos de 100 Hz aún disponibles."}},"fr":{"stringUnit":{"state":"translated","value":"Crée une session depuis l’historique. Inclut les données 100 Hz encore disponibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Cria uma sessão a partir do histórico. Inclui os dados de 100 Hz ainda disponíveis."}},"it":{"stringUnit":{"state":"translated","value":"Crea una sessione dalla cronologia sincronizzata senza avviare un'acquisizione dal vivo. La copertura a 100 Hz è inclusa dove è ancora presente nel buffer."}},"pl":{"stringUnit":{"state":"translated","value":"Utwórz sesję ze zsynchronizowanej historii bez uruchamiania nagrywania na żywo. Pokrycie 100 Hz jest dołączane tam, gdzie wciąż istnieje w buforze."}},"ru":{"stringUnit":{"state":"translated","value":"Создать сессию из синхронизированной истории без запуска живой записи. Покрытие 100 Гц включается там, где оно ещё есть в буфере."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"从已同步的历史创建会话,无需启动实时采集。滚动缓冲区中仍存在的 100 Hz 数据会一并包含。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"從已同步的歷史建立工作階段,無需啟動即時擷取。輪替緩衝區中仍存在的 100 Hz 資料會一併包含。"}}}},
+ "Recorded sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aufgezeichnete Sessions"}},"es":{"stringUnit":{"state":"translated","value":"Sesiones grabadas"}},"fr":{"stringUnit":{"state":"translated","value":"Sessions enregistrées"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sessões gravadas"}},"it":{"stringUnit":{"state":"translated","value":"Sessioni registrate"}},"pl":{"stringUnit":{"state":"translated","value":"Zarejestrowane sesje"}},"ru":{"stringUnit":{"state":"translated","value":"Записанные сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"已记录会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"已記錄工作階段"}}}},
+ "No sessions recorded yet.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Noch keine Sessions aufgezeichnet."}},"es":{"stringUnit":{"state":"translated","value":"Aún no hay sesiones grabadas."}},"fr":{"stringUnit":{"state":"translated","value":"Aucune session enregistrée."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ainda não existem sessões gravadas."}},"it":{"stringUnit":{"state":"translated","value":"Nessuna sessione registrata."}},"pl":{"stringUnit":{"state":"translated","value":"Nie zarejestrowano jeszcze żadnych sesji."}},"ru":{"stringUnit":{"state":"translated","value":"Сессии ещё не записаны."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"尚未记录任何会话。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"尚未記錄任何工作階段。"}}}},
+ "Export status: recording": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: Aufnahme läuft"}},"es":{"stringUnit":{"state":"translated","value":"Estado: grabando"}},"fr":{"stringUnit":{"state":"translated","value":"État : enregistrement"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: a gravar"}},"it":{"stringUnit":{"state":"translated","value":"Stato esportazione: registrazione"}},"pl":{"stringUnit":{"state":"translated","value":"Stan eksportu: nagrywanie"}},"ru":{"stringUnit":{"state":"translated","value":"Состояние экспорта: запись"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出状态:正在记录"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出狀態:記錄中"}}}},
+ "IMU: %@": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"es":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"fr":{"stringUnit":{"state":"translated","value":"IMU : %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"it":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"pl":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"ru":{"stringUnit":{"state":"translated","value":"IMU: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"IMU:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"IMU:%@"}}}},
+ "Last exported %@ · export remains available": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zuletzt exportiert: %@ · Export bleibt verfügbar"}},"es":{"stringUnit":{"state":"translated","value":"Última exportación: %@ · la exportación sigue disponible"}},"fr":{"stringUnit":{"state":"translated","value":"Dernier export : %@ · l’export reste disponible"}},"it":{"stringUnit":{"state":"translated","value":"Ultima esportazione: %@ · l’esportazione resta disponibile"}},"pl":{"stringUnit":{"state":"translated","value":"Ostatnio wyeksportowano: %@ · eksport pozostaje dostępny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Última exportação: %@ · a exportação continua disponível"}},"ru":{"stringUnit":{"state":"translated","value":"Последний экспорт: %@ · экспорт остаётся доступен"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"上次导出:%@ · 仍可再次导出"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"上次匯出:%@ · 仍可再次匯出"}}}},
+ "Export status: %lld raw batches": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Exportstatus: %lld Rohdatenblöcke"}},"es":{"stringUnit":{"state":"translated","value":"Estado: %lld bloques sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"État : %lld blocs bruts"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Estado: %lld blocos em bruto"}},"it":{"stringUnit":{"state":"translated","value":"Stato esportazione: %lld blocchi di dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Stan eksportu: %lld partii surowych danych"}},"ru":{"stringUnit":{"state":"translated","value":"Состояние экспорта: %lld пакетов сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出状态:%lld 批原始数据"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出狀態:%lld 批原始資料"}}}},
+ "Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Experiment. Für normalen 5/MG-Betrieb und den Rohdaten-Collector ist es nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Experimento R22 antiguo. No es necesario para el uso normal ni para el recopilador de datos."}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience R22. Elle n’est pas requise pour l’usage normal ni pour le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência R22 antiga. Não é necessária para o uso normal nem para o coletor."}},"it":{"stringUnit":{"state":"translated","value":"Vecchio esperimento con i flag R22. La fascia accetta queste scritture, ma NOOP non ha osservato l'attivazione di un flusso dal vivo separato. Non è necessario per il normale supporto WHOOP 5/MG né per il Raccoglitore dati grezzi. Scrive impostazioni persistenti sulla fascia e potrebbe non avere effetto sul tuo firmware."}},"pl":{"stringUnit":{"state":"translated","value":"Dawny eksperyment z flagami R22. Pasek przyjmuje te zapisy, ale NOOP nie zaobserwował uruchomienia osobnego strumienia na żywo. Nie jest to wymagane do normalnej obsługi WHOOP 5/MG ani do Kolektora danych surowych. Zapisuje trwałe ustawienia paska i może nie działać na Twoim oprogramowaniu."}},"ru":{"stringUnit":{"state":"translated","value":"Старый эксперимент с флагами R22. Браслет принимает эти записи, но NOOP не наблюдал включения отдельного живого потока. Для обычной поддержки WHOOP 5/MG и Сборщика сырых данных это не требуется. Записывает постоянные настройки браслета и может ничего не дать на вашей прошивке."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"旧的 R22 功能标记实验。手环会接受这些写入,但 NOOP 未观察到由此启用独立的实时数据流。正常的 WHOOP 5/MG 支持和原始数据采集器都不需要它。它会写入持久的手环设置,在你的固件上可能毫无作用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"舊的 R22 功能標記實驗。手環會接受這些寫入,但 NOOP 未觀察到因此啟用獨立的即時資料流。正常的 WHOOP 5/MG 支援與原始資料收集器都不需要它。它會寫入持久的手環設定,在你的韌體上可能毫無作用。"}}}},
+ "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP schätzt Schritte aus gespeicherter Bewegung und kalibriert sie mit dem Telefon. Es ist kein Hardware-Schrittzähler."}},"es":{"stringUnit":{"state":"translated","value":"NOOP estima los pasos desde el movimiento y los calibra con el teléfono. No es un contador físico."}},"fr":{"stringUnit":{"state":"translated","value":"NOOP estime les pas depuis le mouvement et les calibre avec le téléphone. Ce n’est pas un compteur matériel."}},"pt-PT":{"stringUnit":{"state":"translated","value":"O NOOP estima os passos a partir do movimento e calibra-os com o telefone. Não é um contador físico."}},"it":{"stringUnit":{"state":"translated","value":"NOOP stima i tuoi passi dal movimento memorizzato dal WHOOP, calibrato sul contapassi del telefono. È una stima, non un contapassi hardware; i dati di movimento arrivano dalla normale sincronizzazione della cronologia WHOOP 5/MG."}},"pl":{"stringUnit":{"state":"translated","value":"NOOP szacuje kroki na podstawie ruchu zapisanego przez WHOOP, kalibrując je licznikiem kroków telefonu. To oszacowanie, a nie sprzętowy krokomierz; dane o ruchu pochodzą ze zwykłej synchronizacji historii WHOOP 5/MG."}},"ru":{"stringUnit":{"state":"translated","value":"NOOP оценивает шаги по сохранённому движению WHOOP, калибруя их по шагомеру телефона. Это оценка, а не аппаратный шагомер; данные о движении приходят из обычной синхронизации истории WHOOP 5/MG."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"NOOP 根据 WHOOP 存储的运动数据估算步数,并以手机步数进行校准。这是估算值,而非硬件计步器;运动数据来自正常的 WHOOP 5/MG 历史同步。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"NOOP 根據 WHOOP 儲存的運動資料估算步數,並以手機步數進行校準。這是估算值,而非硬體計步器;運動資料來自正常的 WHOOP 5/MG 歷史同步。"}}}},
+ "5/MG RAW DATA COLLECTOR": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-ROHDATEN-COLLECTOR"}},"es":{"stringUnit":{"state":"translated","value":"RECOPILADOR DE DATOS 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"COLLECTEUR DE DONNÉES 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"RECOLHA DE DADOS 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"RACCOGLITORE DATI GREZZI 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"KOLEKTOR DANYCH SUROWYCH 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"СБОРЩИК СЫРЫХ ДАННЫХ 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 原始資料收集器"}}}},
+ "Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Begrenzte 100-Hz-Sessions aufnehmen, prüfen, exportieren und löschen. Normale Synchronisierung bleibt unverändert."}},"es":{"stringUnit":{"state":"translated","value":"Graba, revisa, exporta y elimina sesiones limitadas a 100 Hz. La sincronización normal no cambia."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez, consultez, exportez et supprimez des sessions limitées à 100 Hz. La synchronisation normale ne change pas."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave, reveja, exporte e elimine sessões limitadas a 100 Hz. A sincronização normal não muda."}},"it":{"stringUnit":{"state":"translated","value":"Registra, esamina, esporta ed elimina sessioni di movimento a 100 Hz delimitate. La sincronizzazione normale non cambia."}},"pl":{"stringUnit":{"state":"translated","value":"Nagrywaj, przeglądaj, eksportuj i usuwaj ograniczone sesje ruchu 100 Hz. Zwykła synchronizacja pozostaje bez zmian."}},"ru":{"stringUnit":{"state":"translated","value":"Записывайте, просматривайте, экспортируйте и удаляйте ограниченные сессии движения 100 Гц. Обычная синхронизация не меняется."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录、查看、导出和删除有时长限制的 100 Hz 运动会话。正常同步不受影响。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄、檢視、匯出並刪除有時長限制的 100 Hz 運動工作階段。正常同步不受影響。"}}}},
+ "Open raw-data collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Collector öffnen"}},"es":{"stringUnit":{"state":"translated","value":"Abrir recopilador de datos"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir le collecteur de données"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Abrir recolha de dados"}},"it":{"stringUnit":{"state":"translated","value":"Apri il raccoglitore dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Otwórz kolektor danych surowych"}},"ru":{"stringUnit":{"state":"translated","value":"Открыть сборщик сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"打开原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"開啟原始資料收集器"}}}},
+ "WHOOP 5/MG connected.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG verbunden."}},"es":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG conectado."}},"fr":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG connecté."}},"pt-PT":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG ligado."}},"it":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG collegato."}},"pl":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG połączony."}},"ru":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG подключён."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG 已连接。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP 5/MG 已連接。"}}}},
+ "Connect your WHOOP 5/MG to start a capture.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Aufnahme zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una captura."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour lancer une capture."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma captura."}},"it":{"stringUnit":{"state":"translated","value":"Collega il tuo WHOOP 5/MG per avviare un'acquisizione."}},"pl":{"stringUnit":{"state":"translated","value":"Połącz WHOOP 5/MG, aby rozpocząć nagrywanie."}},"ru":{"stringUnit":{"state":"translated","value":"Подключите WHOOP 5/MG, чтобы начать запись."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接你的 WHOOP 5/MG 以开始采集。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接你的 WHOOP 5/MG 以開始擷取。"}}}},
+ "5/MG PROTOCOL DIAGNOSTICS": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-PROTOKOLLDIAGNOSE"}},"es":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DEL PROTOCOLO 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"DIAGNOSTICS DU PROTOCOLE 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"DIAGNÓSTICO DO PROTOCOLO 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"DIAGNOSTICA PROTOCOLLO 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"DIAGNOSTYKA PROTOKOŁU 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"ДИАГНОСТИКА ПРОТОКОЛА 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 协议诊断"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 協定診斷"}}}},
+ "Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Entwicklerwerkzeuge für nicht zugeordnete Protokollfunktionen. Für normale Aufnahme, Verlauf und Rohdaten-Collector nicht erforderlich."}},"es":{"stringUnit":{"state":"translated","value":"Herramientas para funciones de protocolo sin asignar. No son necesarias para el uso normal."}},"fr":{"stringUnit":{"state":"translated","value":"Outils pour les fonctions de protocole non identifiées. Ils ne sont pas requis pour l’usage normal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ferramentas para funções de protocolo não identificadas. Não são necessárias para o uso normal."}},"it":{"stringUnit":{"state":"translated","value":"Strumenti per sviluppatori per funzioni di protocollo non mappate. Nessuno è necessario per la normale registrazione WHOOP 5/MG, la sincronizzazione della cronologia o il Raccoglitore dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Narzędzia deweloperskie do niezmapowanych funkcji protokołu. Żadne z nich nie są wymagane do zwykłego nagrywania WHOOP 5/MG, synchronizacji historii ani Kolektora danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Инструменты разработчика для неразобранных функций протокола. Ничего из этого не требуется для обычной записи WHOOP 5/MG, синхронизации истории или Сборщика сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"用于未映射协议功能的开发者工具。正常的 WHOOP 5/MG 记录、历史同步和原始数据采集器都不需要这些。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"用於未對應協定功能的開發者工具。正常的 WHOOP 5/MG 記錄、歷史同步與原始資料收集器都不需要這些。"}}}},
+ "Protocol probes": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Protokolltests"}},"es":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Sondes de protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Sondas de protocolo"}},"it":{"stringUnit":{"state":"translated","value":"Sonde di protocollo"}},"pl":{"stringUnit":{"state":"translated","value":"Sondy protokołu"}},"ru":{"stringUnit":{"state":"translated","value":"Пробы протокола"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"协议探测"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"協定探測"}}}},
+ "Sends experimental protocol queries and records the replies in the strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Sendet experimentelle Abfragen und protokolliert die Antworten."}},"es":{"stringUnit":{"state":"translated","value":"Envía consultas experimentales y registra las respuestas."}},"fr":{"stringUnit":{"state":"translated","value":"Envoie des requêtes expérimentales et consigne les réponses."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Envia consultas experimentais e regista as respostas."}},"it":{"stringUnit":{"state":"translated","value":"Invia query sperimentali e registra le risposte nel log della fascia."}},"pl":{"stringUnit":{"state":"translated","value":"Wysyła eksperymentalne zapytania protokołu i zapisuje odpowiedzi w dzienniku paska."}},"ru":{"stringUnit":{"state":"translated","value":"Отправляет экспериментальные запросы протокола и записывает ответы в журнал браслета."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"发送实验性协议查询,并将回复记录到手环日志中。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"傳送實驗性協定查詢,並將回覆記錄到手環日誌中。"}}}},
+ "Broadcast heart rate from the strap": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Herzfrequenz vom Band übertragen"}},"es":{"stringUnit":{"state":"translated","value":"Transmitir frecuencia cardíaca desde la pulsera"}},"fr":{"stringUnit":{"state":"translated","value":"Diffuser la fréquence cardiaque du bracelet"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Transmitir frequência cardíaca da bracelete"}},"it":{"stringUnit":{"state":"translated","value":"Trasmetti la frequenza cardiaca dalla fascia"}},"pl":{"stringUnit":{"state":"translated","value":"Nadawaj tętno z paska"}},"ru":{"stringUnit":{"state":"translated","value":"Транслировать пульс с браслета"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"从手环广播心率"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"從手環廣播心率"}}}},
+ "Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Setzt das umkehrbare 5/MG-Werbe-Flag für kompatible Geräte."}},"es":{"stringUnit":{"state":"translated","value":"Activa el indicador reversible 5/MG para equipos compatibles."}},"fr":{"stringUnit":{"state":"translated","value":"Active l’indicateur réversible 5/MG pour les appareils compatibles."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ativa o indicador reversível 5/MG para equipamento compatível."}},"it":{"stringUnit":{"state":"translated","value":"Scrive il flag di advertising 5/MG reversibile per Garmin, Zwift e attrezzature da palestra compatibili."}},"pl":{"stringUnit":{"state":"translated","value":"Zapisuje odwracalną flagę rozgłaszania 5/MG dla Garmina, Zwifta i zgodnego sprzętu na siłowni."}},"ru":{"stringUnit":{"state":"translated","value":"Записывает обратимый флаг вещания 5/MG для Garmin, Zwift и совместимых тренажёров."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"写入可撤销的 5/MG 广播标记,供 Garmin、Zwift 及兼容健身器材使用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"寫入可撤銷的 5/MG 廣播標記,供 Garmin、Zwift 及相容健身器材使用。"}}}},
+ "Legacy R22 feature-flag experiment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Altes R22-Feature-Flag-Experiment"}},"es":{"stringUnit":{"state":"translated","value":"Experimento antiguo de indicadores R22"}},"fr":{"stringUnit":{"state":"translated","value":"Ancienne expérience d’indicateurs R22"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Experiência antiga de indicadores R22"}},"it":{"stringUnit":{"state":"translated","value":"Vecchio esperimento con i flag R22"}},"pl":{"stringUnit":{"state":"translated","value":"Dawny eksperyment z flagami R22"}},"ru":{"stringUnit":{"state":"translated","value":"Старый эксперимент с флагами R22"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"旧的 R22 功能标记实验"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"舊的 R22 功能標記實驗"}}}},
+ "The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Das Band akzeptiert die Schreibvorgänge, ein eigener Live-Stream wurde aber nicht beobachtet. Dies ist nicht der Rohdaten-Collector."}},"es":{"stringUnit":{"state":"translated","value":"La pulsera acepta estas escrituras, pero no se ha observado otro flujo. Esto no es el recopilador."}},"fr":{"stringUnit":{"state":"translated","value":"Le bracelet accepte ces écritures, mais aucun flux distinct n’a été observé. Ce n’est pas le collecteur."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A bracelete aceita estas escritas, mas não foi observado outro fluxo. Isto não é a recolha de dados."}},"it":{"stringUnit":{"state":"translated","value":"La fascia accetta queste scritture, ma NOOP non ha osservato l'attivazione di un flusso dal vivo separato. Questo non è il Raccoglitore dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Pasek przyjmuje te zapisy, ale NOOP nie zaobserwował uruchomienia osobnego strumienia na żywo. To nie jest Kolektor danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Браслет принимает эти записи, но NOOP не наблюдал включения отдельного живого потока. Это не Сборщик сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"手环会接受这些写入,但 NOOP 未观察到由此启用独立的实时数据流。这不是原始数据采集器。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"手環會接受這些寫入,但 NOOP 未觀察到因此啟用獨立的即時資料流。這不是原始資料收集器。"}}}},
+ "WHOOP MG ECG raw-data gate": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"WHOOP-MG-Schalter für ECG-Rohdaten"}},"es":{"stringUnit":{"state":"translated","value":"Control de datos ECG de WHOOP MG"}},"fr":{"stringUnit":{"state":"translated","value":"Contrôle des données ECG WHOOP MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Controlo de dados ECG WHOOP MG"}},"it":{"stringUnit":{"state":"translated","value":"Interruttore dati grezzi ECG per WHOOP MG"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącznik surowych danych EKG WHOOP MG"}},"ru":{"stringUnit":{"state":"translated","value":"Переключатель сырых данных ЭКГ WHOOP MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"WHOOP MG 心电原始数据开关"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"WHOOP MG 心電原始資料開關"}}}},
+ "MG-only protocol instrumentation, not a medical ECG feature.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Nur MG-Protokolltests, keine medizinische ECG-Funktion."}},"es":{"stringUnit":{"state":"translated","value":"Instrumentación solo para MG; no es una función ECG médica."}},"fr":{"stringUnit":{"state":"translated","value":"Instrumentation MG uniquement, pas une fonction ECG médicale."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Instrumentação apenas para MG; não é uma função ECG médica."}},"it":{"stringUnit":{"state":"translated","value":"Strumentazione di protocollo solo per MG, non una funzione ECG medica."}},"pl":{"stringUnit":{"state":"translated","value":"Instrumentacja protokołu tylko dla MG, nie jest to medyczna funkcja EKG."}},"ru":{"stringUnit":{"state":"translated","value":"Протокольная инструментовка только для MG, а не медицинская функция ЭКГ."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"仅限 MG 的协议检测,并非医疗心电功能。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"僅限 MG 的協定檢測,並非醫療心電功能。"}}}},
+ "Passive history/protocol trace": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Passive Verlaufs-/Protokollaufzeichnung"}},"es":{"stringUnit":{"state":"translated","value":"Registro pasivo de historial/protocolo"}},"fr":{"stringUnit":{"state":"translated","value":"Trace passive d’historique/protocole"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Registo passivo de histórico/protocolo"}},"it":{"stringUnit":{"state":"translated","value":"Traccia passiva di cronologia/protocollo"}},"pl":{"stringUnit":{"state":"translated","value":"Pasywny zapis historii/protokołu"}},"ru":{"stringUnit":{"state":"translated","value":"Пассивная трассировка истории/протокола"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"被动历史/协议追踪"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"被動歷史/協定追蹤"}}}},
+ "Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zeichnet bereits eintreffende Frames auf, startet keine Sensoren und kann große Dateien erzeugen. Der Export unten fügt das Bandprotokoll hinzu."}},"es":{"stringUnit":{"state":"translated","value":"Registra las tramas que ya llegan. No inicia sensores y puede crear archivos grandes. El export inferior incluye el registro."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistre les trames déjà reçues. Ne démarre aucun capteur et peut créer de gros fichiers. L’export ci-dessous joint le journal."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Regista os pacotes que já chegam. Não inicia sensores e pode criar ficheiros grandes. A exportação abaixo inclui o registo."}},"it":{"stringUnit":{"state":"translated","value":"Registra i frame che arrivano già. Non avvia i sensori e può creare file di grandi dimensioni. Usa la sezione di esportazione qui sotto per salvare la traccia con il log della fascia."}},"pl":{"stringUnit":{"state":"translated","value":"Zapisuje ramki, które i tak przychodzą. Nie uruchamia czujników i może tworzyć duże pliki. Użyj sekcji eksportu poniżej, aby zapisać zapis wraz z dziennikiem paska."}},"ru":{"stringUnit":{"state":"translated","value":"Записывает кадры, которые и так приходят. Не запускает датчики и может создавать большие файлы. Используйте раздел экспорта ниже, чтобы сохранить трассировку вместе с журналом браслета."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录本就到达的数据帧。它不会启动传感器,并可能生成很大的文件。请使用下方的导出部分,将追踪记录连同手环日志一起保存。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄本就到達的資料框。它不會啟動感測器,並可能產生很大的檔案。請使用下方的匯出區塊,將追蹤記錄連同手環日誌一起儲存。"}}}},
+ "Couldn't delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session konnte nicht gelöscht werden"}},"es":{"stringUnit":{"state":"translated","value":"No se pudo eliminar la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Impossible de supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível eliminar a sessão"}},"it":{"stringUnit":{"state":"translated","value":"Impossibile eliminare la sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Nie można usunąć sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось удалить сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法删除会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法刪除工作階段"}}}},
+ "5/MG Raw Data Collector": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"5/MG-Rohdaten-Collector"}},"es":{"stringUnit":{"state":"translated","value":"Recopilador de datos sin procesar 5/MG"}},"fr":{"stringUnit":{"state":"translated","value":"Collecteur de données brutes 5/MG"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Recolha de dados em bruto 5/MG"}},"it":{"stringUnit":{"state":"translated","value":"Raccoglitore dati grezzi 5/MG"}},"pl":{"stringUnit":{"state":"translated","value":"Kolektor danych surowych 5/MG"}},"ru":{"stringUnit":{"state":"translated","value":"Сборщик сырых данных 5/MG"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"5/MG 原始数据采集器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"5/MG 原始資料收集器"}}}},
+ "Record a bounded 100 Hz motion session and export its complete timeline.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Eine begrenzte 100-Hz-Bewegungssession aufnehmen und vollständig exportieren."}},"es":{"stringUnit":{"state":"translated","value":"Graba una sesión de movimiento limitada a 100 Hz y exporta su cronología completa."}},"fr":{"stringUnit":{"state":"translated","value":"Enregistrez une session de mouvement 100 Hz limitée et exportez sa chronologie complète."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Grave uma sessão de movimento limitada a 100 Hz e exporte a cronologia completa."}},"it":{"stringUnit":{"state":"translated","value":"Registra una sessione di movimento a 100 Hz delimitata ed esportane la cronologia completa."}},"pl":{"stringUnit":{"state":"translated","value":"Nagraj ograniczoną sesję ruchu 100 Hz i wyeksportuj jej pełną oś czasu."}},"ru":{"stringUnit":{"state":"translated","value":"Запишите ограниченную сессию движения 100 Гц и экспортируйте её полную временную шкалу."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"记录一段有时长限制的 100 Hz 运动会话,并导出其完整时间轴。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"記錄一段有時長限制的 100 Hz 運動工作階段,並匯出其完整時間軸。"}}}},
+ "Start raw-data session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Rohdaten-Session starten"}},"es":{"stringUnit":{"state":"translated","value":"Iniciar sesión de datos sin procesar"}},"fr":{"stringUnit":{"state":"translated","value":"Démarrer la session de données brutes"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Iniciar sessão de dados em bruto"}},"it":{"stringUnit":{"state":"translated","value":"Avvia sessione dati grezzi"}},"pl":{"stringUnit":{"state":"translated","value":"Rozpocznij sesję danych surowych"}},"ru":{"stringUnit":{"state":"translated","value":"Начать сессию сырых данных"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"开始原始数据会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"開始原始資料工作階段"}}}},
+ "Add historical session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Historische Session hinzufügen"}},"es":{"stringUnit":{"state":"translated","value":"Añadir sesión histórica"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter une session historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar sessão histórica"}},"it":{"stringUnit":{"state":"translated","value":"Aggiungi sessione storica"}},"pl":{"stringUnit":{"state":"translated","value":"Dodaj sesję historyczną"}},"ru":{"stringUnit":{"state":"translated","value":"Добавить историческую сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"添加历史会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"加入歷史工作階段"}}}},
+ "Delete all sessions": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Alle Sessions löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar todas las sesiones"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer toutes les sessions"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar todas as sessões"}},"it":{"stringUnit":{"state":"translated","value":"Elimina tutte le sessioni"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń wszystkie sesje"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить все сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除所有会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除所有工作階段"}}}},
+ "Session comment": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session-Kommentar"}},"es":{"stringUnit":{"state":"translated","value":"Comentario de la sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Commentaire de session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Comentário da sessão"}},"it":{"stringUnit":{"state":"translated","value":"Commento della sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Komentarz do sesji"}},"ru":{"stringUnit":{"state":"translated","value":"Комментарий к сессии"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"会话备注"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"工作階段備註"}}}},
+ "Building export…": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Export wird erstellt …"}},"es":{"stringUnit":{"state":"translated","value":"Creando exportación…"}},"fr":{"stringUnit":{"state":"translated","value":"Création de l’export…"}},"pt-PT":{"stringUnit":{"state":"translated","value":"A criar exportação…"}},"it":{"stringUnit":{"state":"translated","value":"Creazione esportazione…"}},"pl":{"stringUnit":{"state":"translated","value":"Tworzenie eksportu…"}},"ru":{"stringUnit":{"state":"translated","value":"Формирование экспорта…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"正在生成导出…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"正在建立匯出…"}}}},
+ "Export session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session exportieren"}},"es":{"stringUnit":{"state":"translated","value":"Exportar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Exporter la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Exportar sessão"}},"it":{"stringUnit":{"state":"translated","value":"Esporta sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Eksportuj sesję"}},"ru":{"stringUnit":{"state":"translated","value":"Экспортировать сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"导出会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"匯出工作階段"}}}},
+ "Delete session": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Session löschen"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar sesión"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer la session"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar sessão"}},"it":{"stringUnit":{"state":"translated","value":"Elimina sessione"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń sesję"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить сессию"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除工作階段"}}}},
+ "The export file could not be created or shared.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Exportdatei konnte nicht erstellt oder geteilt werden."}},"es":{"stringUnit":{"state":"translated","value":"No se pudo crear ni compartir el archivo de exportación."}},"fr":{"stringUnit":{"state":"translated","value":"Le fichier d’export n’a pas pu être créé ou partagé."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Não foi possível criar ou partilhar o ficheiro de exportação."}},"it":{"stringUnit":{"state":"translated","value":"Impossibile creare o condividere il file di esportazione."}},"pl":{"stringUnit":{"state":"translated","value":"Nie udało się utworzyć ani udostępnić pliku eksportu."}},"ru":{"stringUnit":{"state":"translated","value":"Не удалось создать файл экспорта или поделиться им."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"无法创建或共享导出文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法建立或分享匯出檔案。"}}}},
+ "To": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Bis"}},"es":{"stringUnit":{"state":"translated","value":"Hasta"}},"fr":{"stringUnit":{"state":"translated","value":"Au"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Até"}},"it":{"stringUnit":{"state":"translated","value":"A"}},"pl":{"stringUnit":{"state":"translated","value":"Do"}},"ru":{"stringUnit":{"state":"translated","value":"До"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"至"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"至"}}}},
+ "0": {"shouldTranslate":false},
+ "Connect your WHOOP 5/MG to start a raw-data session.": {"shouldTranslate":false,"localizations":{"de":{"stringUnit":{"state":"translated","value":"Verbinde dein WHOOP 5/MG, um eine Rohdaten-Session zu starten."}},"es":{"stringUnit":{"state":"translated","value":"Conecta tu WHOOP 5/MG para iniciar una sesión de datos sin procesar."}},"fr":{"stringUnit":{"state":"translated","value":"Connectez votre WHOOP 5/MG pour démarrer une session de données brutes."}},"pt-PT":{"stringUnit":{"state":"translated","value":"Ligue o WHOOP 5/MG para iniciar uma sessão de dados em bruto."}},"it":{"stringUnit":{"state":"translated","value":"Collega il tuo WHOOP 5/MG per avviare una sessione dati grezzi."}},"pl":{"stringUnit":{"state":"translated","value":"Połącz WHOOP 5/MG, aby rozpocząć sesję danych surowych."}},"ru":{"stringUnit":{"state":"translated","value":"Подключите WHOOP 5/MG, чтобы начать сессию сырых данных."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接你的 WHOOP 5/MG 以开始原始数据会话。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接你的 WHOOP 5/MG 以開始原始資料工作階段。"}}}},
+ "Add marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker hinzufügen"}},"en":{"stringUnit":{"state":"translated","value":"Add marker"}},"es":{"stringUnit":{"state":"translated","value":"Añadir marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Ajouter un marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Aggiungi indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Dodaj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Adicionar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Добавить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"添加标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"加入標記"}}}},
+ "Edit marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker bearbeiten"}},"en":{"stringUnit":{"state":"translated","value":"Edit marker"}},"es":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Modifier le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Modifica indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Edytuj znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Editar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯標記"}}}},
+ "Delete marker": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker löschen"}},"en":{"stringUnit":{"state":"translated","value":"Delete marker"}},"es":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Supprimer le marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Elimina indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Usuń znacznik"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Eliminar marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Удалить маркер"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"删除标记"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"刪除標記"}}}},
+ "Marker type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Markertyp"}},"en":{"stringUnit":{"state":"translated","value":"Marker type"}},"es":{"stringUnit":{"state":"translated","value":"Tipo de marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Type de marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Tipo di indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Typ znacznika"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo de marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Тип маркера"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记类型"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記類型"}}}},
+ "Marker note": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Markernotiz"}},"en":{"stringUnit":{"state":"translated","value":"Marker note"}},"es":{"stringUnit":{"state":"translated","value":"Nota del marcador"}},"fr":{"stringUnit":{"state":"translated","value":"Note du marqueur"}},"it":{"stringUnit":{"state":"translated","value":"Nota indicatore"}},"pl":{"stringUnit":{"state":"translated","value":"Notatka znacznika"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Nota do marcador"}},"ru":{"stringUnit":{"state":"translated","value":"Заметка маркера"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记备注"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記備註"}}}},
+ "Current time: %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Aktuelle Zeit: %@"}},"en":{"stringUnit":{"state":"translated","value":"Current time: %@"}},"es":{"stringUnit":{"state":"translated","value":"Hora actual: %@"}},"fr":{"stringUnit":{"state":"translated","value":"Heure actuelle : %@"}},"it":{"stringUnit":{"state":"translated","value":"Ora attuale: %@"}},"pl":{"stringUnit":{"state":"translated","value":"Bieżący czas: %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Hora atual: %@"}},"ru":{"stringUnit":{"state":"translated","value":"Текущее время: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"当前时间:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"目前時間:%@"}}}},
+ "Marker: %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Marker: %@"}},"en":{"stringUnit":{"state":"translated","value":"Marker: %@"}},"es":{"stringUnit":{"state":"translated","value":"Marcador: %@"}},"fr":{"stringUnit":{"state":"translated","value":"Marqueur : %@"}},"it":{"stringUnit":{"state":"translated","value":"Indicatore: %@"}},"pl":{"stringUnit":{"state":"translated","value":"Znacznik: %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Marcador: %@"}},"ru":{"stringUnit":{"state":"translated","value":"Маркер: %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"标记:%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"標記:%@"}}}},
+ "Moment": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Moment"}},"en":{"stringUnit":{"state":"translated","value":"Moment"}},"es":{"stringUnit":{"state":"translated","value":"Momento"}},"fr":{"stringUnit":{"state":"translated","value":"Moment"}},"it":{"stringUnit":{"state":"translated","value":"Momento"}},"pl":{"stringUnit":{"state":"translated","value":"Moment"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Momento"}},"ru":{"stringUnit":{"state":"translated","value":"Момент"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"时刻"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"時刻"}}}},
+ "Issue": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Problem"}},"en":{"stringUnit":{"state":"translated","value":"Issue"}},"es":{"stringUnit":{"state":"translated","value":"Problema"}},"fr":{"stringUnit":{"state":"translated","value":"Problème"}},"it":{"stringUnit":{"state":"translated","value":"Problema"}},"pl":{"stringUnit":{"state":"translated","value":"Problem"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Problema"}},"ru":{"stringUnit":{"state":"translated","value":"Проблема"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"问题"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"問題"}}}},
+ "−10 s": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"−10 s"}},"en":{"stringUnit":{"state":"translated","value":"−10 s"}},"es":{"stringUnit":{"state":"translated","value":"−10 s"}},"fr":{"stringUnit":{"state":"translated","value":"−10 s"}},"it":{"stringUnit":{"state":"translated","value":"−10 s"}},"pl":{"stringUnit":{"state":"translated","value":"−10 s"}},"pt-PT":{"stringUnit":{"state":"translated","value":"−10 s"}},"ru":{"stringUnit":{"state":"translated","value":"−10 с"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"−10 秒"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"−10 秒"}}}},
+ "+10 s": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"+10 s"}},"en":{"stringUnit":{"state":"translated","value":"+10 s"}},"es":{"stringUnit":{"state":"translated","value":"+10 s"}},"fr":{"stringUnit":{"state":"translated","value":"+10 s"}},"it":{"stringUnit":{"state":"translated","value":"+10 s"}},"pl":{"stringUnit":{"state":"translated","value":"+10 s"}},"pt-PT":{"stringUnit":{"state":"translated","value":"+10 s"}},"ru":{"stringUnit":{"state":"translated","value":"+10 с"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"+10 秒"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"+10 秒"}}}},
+ " · %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":" · %@"}},"en":{"stringUnit":{"state":"translated","value":" · %@"}},"es":{"stringUnit":{"state":"translated","value":" · %@"}},"fr":{"stringUnit":{"state":"translated","value":" · %@"}},"it":{"stringUnit":{"state":"translated","value":" · %@"}},"pl":{"stringUnit":{"state":"translated","value":" · %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":" · %@"}},"ru":{"stringUnit":{"state":"translated","value":" · %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":" · %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":" · %@"}}}},
+ "Check what the strap has stored": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Prüfen, was das Band gespeichert hat" } }, "en": { "stringUnit": { "state": "translated", "value": "Check what the strap has stored" } }, "es": { "stringUnit": { "state": "translated", "value": "Comprobar qué tiene guardado la correa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Vérifier ce que le bracelet a enregistré" } }, "it": { "stringUnit": { "state": "translated", "value": "Controlla cosa ha memorizzato il braccialetto" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sprawdź, co zapisała opaska" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Verificar o que a pulseira tem guardado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Проверить, что сохранено на ремешке" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "查看手环已存储的闹钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "查看手環已儲存的鬧鐘" } }
+ } },
+ "The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with.": { "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Die Antwort des Bands erscheint im Band-Log und im Diagnose-Export, samt der empfangenen Rohbytes." } }, "en": { "stringUnit": { "state": "translated", "value": "The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with." } }, "es": { "stringUnit": { "state": "translated", "value": "La respuesta de la correa aparece en el registro y en la exportación de diagnóstico, con los bytes recibidos." } }, "fr": { "stringUnit": { "state": "translated", "value": "La réponse du bracelet apparaît dans le journal et l’export de diagnostic, avec les octets bruts reçus." } }, "it": { "stringUnit": { "state": "translated", "value": "La risposta del braccialetto compare nel log e nell’esportazione diagnostica, con i byte grezzi ricevuti." } }, "pl": { "stringUnit": { "state": "translated", "value": "Odpowiedź opaski pojawi się w logu i eksporcie diagnostycznym, wraz z otrzymanymi surowymi bajtami." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A resposta da pulseira aparece no registo e na exportação de diagnóstico, com os bytes recebidos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Ответ ремешка появится в журнале ремешка и диагностическом экспорте вместе с полученными байтами." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "手环的回应会出现在手环日志和诊断导出中,并附带收到的原始字节。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "手環的回應會出現在手環日誌和診斷匯出中,並附上收到的原始位元組。" } }
+ } },
+ "Last night against your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Letzte Nacht im Vergleich zu deiner inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Anoche frente a tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"La nuit dernière face à votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"La scorsa notte rispetto al tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Ostatnia noc a Twój zegar biologiczny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"A noite passada face ao seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Прошлая ночь и ваши биологические часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"昨夜与你的生物钟对比"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"昨夜與你的生理時鐘對比"}}}},
+ "In sync with your body clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Im Einklang mit deiner inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"En sintonía con tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"En phase avec votre horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"In sintonia con il tuo orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"Zgodnie z zegarem biologicznym"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Em sintonia com o seu relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"В согласии с биологическими часами"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"与生物钟同步"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"與生理時鐘同步"}}}},
+ "%@ later than your body clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%@ später als deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"%@ más tarde que tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"%@ plus tard que votre horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"%@ più tardi del tuo orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"%@ później niż Twój zegar biologiczny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%@ mais tarde do que o seu relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"На %@ позже биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"比生物钟晚 %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"比生理時鐘晚 %@"}}}},
+ "%@ earlier than your body clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%@ früher als deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"%@ más temprano que tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"%@ plus tôt que votre horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"%@ più presto del tuo orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"%@ wcześniej niż Twój zegar biologiczny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%@ mais cedo do que o seu relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"На %@ раньше биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"比生物钟早 %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"比生理時鐘早 %@"}}}},
+ "Morning type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Morgentyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo matutino"}},"fr":{"stringUnit":{"state":"translated","value":"Type matinal"}},"it":{"stringUnit":{"state":"translated","value":"Tipo mattutino"}},"pl":{"stringUnit":{"state":"translated","value":"Typ poranny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo matutino"}},"ru":{"stringUnit":{"state":"translated","value":"Утренний тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"晨型人"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"晨型人"}}}},
+ "Intermediate type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Mischtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"fr":{"stringUnit":{"state":"translated","value":"Type intermédiaire"}},"it":{"stringUnit":{"state":"translated","value":"Tipo intermedio"}},"pl":{"stringUnit":{"state":"translated","value":"Typ pośredni"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo intermédio"}},"ru":{"stringUnit":{"state":"translated","value":"Промежуточный тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"中间型"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"中間型"}}}},
+ "Evening type": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Abendtyp"}},"es":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"fr":{"stringUnit":{"state":"translated","value":"Type vespéral"}},"it":{"stringUnit":{"state":"translated","value":"Tipo serale"}},"pl":{"stringUnit":{"state":"translated","value":"Typ wieczorny"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tipo vespertino"}},"ru":{"stringUnit":{"state":"translated","value":"Вечерний тип"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"夜型人"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"夜型人"}}}},
+ "Body clock dial": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Zifferblatt der inneren Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Esfera del reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Cadran de l'horloge biologique"}},"it":{"stringUnit":{"state":"translated","value":"Quadrante dell'orologio biologico"}},"pl":{"stringUnit":{"state":"translated","value":"Tarcza zegara biologicznego"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Mostrador do relógio biológico"}},"ru":{"stringUnit":{"state":"translated","value":"Циферблат биологических часов"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"生物钟表盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"生理時鐘錶盤"}}}},
+ "Your clock": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Deine innere Uhr"}},"es":{"stringUnit":{"state":"translated","value":"Tu reloj interno"}},"fr":{"stringUnit":{"state":"translated","value":"Votre horloge"}},"it":{"stringUnit":{"state":"translated","value":"Il tuo orologio"}},"pl":{"stringUnit":{"state":"translated","value":"Twój zegar"}},"pt-PT":{"stringUnit":{"state":"translated","value":"O seu relógio"}},"ru":{"stringUnit":{"state":"translated","value":"Ваши часы"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"你的生物钟"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"你的生理時鐘"}}}},
+ "iCloud Drive": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟"}}}},
+ "iCloud Drive › %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"es":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"fr":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"it":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pl":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"ru":{"stringUnit":{"state":"translated","value":"iCloud Drive › %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"iCloud 云盘 › %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"iCloud 雲碟 › %@"}}}},
+ "NOOP (in Files)": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"NOOP (in Dateien)"}},"es":{"stringUnit":{"state":"translated","value":"NOOP (en Archivos)"}},"fr":{"stringUnit":{"state":"translated","value":"NOOP (dans Fichiers)"}},"it":{"stringUnit":{"state":"translated","value":"NOOP (in File)"}},"pl":{"stringUnit":{"state":"translated","value":"NOOP (w Plikach)"}},"pt-PT":{"stringUnit":{"state":"translated","value":"NOOP (em Ficheiros)"}},"ru":{"stringUnit":{"state":"translated","value":"NOOP (в Файлах)"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"NOOP(在“文件”中)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"NOOP(在「檔案」中)"}}}},
+ "Live heart rate is streaming, but your strap is not fully paired. The encrypted pairing is what carries motion, skin temperature, SpO₂ and respiratory rate — without it, sleep is staged from heart rate alone. Buzz, alarms and history sync need it too.": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Die Live-Herzfrequenz wird übertragen, aber dein Strap ist nicht vollständig gekoppelt. Die verschlüsselte Kopplung liefert Bewegung, Hauttemperatur, SpO₂ und Atemfrequenz — ohne sie wird der Schlaf allein aus der Herzfrequenz abgeleitet. Auch Buzz, Wecker und Verlaufssynchronisierung brauchen sie."}},"es":{"stringUnit":{"state":"translated","value":"La frecuencia cardíaca en vivo se está transmitiendo, pero tu correa no está totalmente emparejada. El emparejamiento cifrado es el que aporta movimiento, temperatura de la piel, SpO₂ y frecuencia respiratoria: sin él, el sueño se calcula solo a partir de la frecuencia cardíaca. El zumbido, las alarmas y la sincronización del historial también lo necesitan."}},"fr":{"stringUnit":{"state":"translated","value":"La fréquence cardiaque en direct est transmise, mais votre bracelet n'est pas entièrement appairé. C'est l'appairage chiffré qui transporte le mouvement, la température cutanée, la SpO₂ et la fréquence respiratoire — sans lui, le sommeil est estimé à partir de la seule fréquence cardiaque. Le buzz, les alarmes et la synchronisation de l'historique en ont besoin aussi."}},"it":{"stringUnit":{"state":"translated","value":"La frequenza cardiaca live viene trasmessa, ma il tuo strap non è completamente associato. È l'associazione cifrata a trasportare movimento, temperatura della pelle, SpO₂ e frequenza respiratoria — senza di essa il sonno viene stimato dalla sola frequenza cardiaca. Anche buzz, sveglie e sincronizzazione della cronologia ne hanno bisogno."}},"pl":{"stringUnit":{"state":"translated","value":"Tętno na żywo jest przesyłane, ale opaska nie jest w pełni sparowana. To szyfrowane parowanie przenosi ruch, temperaturę skóry, SpO₂ i częstość oddechów — bez niego sen jest wyznaczany wyłącznie z tętna. Wibracje, alarmy i synchronizacja historii też go wymagają."}},"pt-PT":{"stringUnit":{"state":"translated","value":"A frequência cardíaca em direto está a ser transmitida, mas a sua pulseira não está totalmente emparelhada. É o emparelhamento encriptado que transporta movimento, temperatura da pele, SpO₂ e taxa respiratória — sem ele, o sono é calculado apenas a partir da frequência cardíaca. O zumbido, os alarmes e a sincronização do histórico também precisam dele."}},"ru":{"stringUnit":{"state":"translated","value":"Пульс передаётся в реальном времени, но ремешок сопряжён не полностью. Именно зашифрованное сопряжение передаёт движение, температуру кожи, SpO₂ и частоту дыхания — без него сон определяется только по пульсу. Вибрация, будильники и синхронизация истории тоже требуют его."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"实时心率正在传输,但你的手环尚未完全配对。加密配对才能传输动作、皮肤温度、SpO₂ 和呼吸频率——没有它,睡眠只能依据心率推算。震动、闹钟和历史同步同样需要它。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"即時心率正在傳輸,但你的手環尚未完全配對。加密配對才能傳輸動作、皮膚溫度、SpO₂ 和呼吸頻率——沒有它,睡眠只能依據心率推算。震動、鬧鐘和歷史同步同樣需要它。"}}}},
+ "Clear conversation": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Clear conversation"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Unterhaltung löschen"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Borrar conversación"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Effacer la conversation"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Cancella conversazione"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wyczyść rozmowę"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Limpar conversa"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Очистить разговор"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "清除对话"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "清除對話"}}
+ } },
+ "Clear the saved conversation": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Clear the saved conversation"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Gespeicherte Unterhaltung löschen"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Borrar la conversación guardada"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Effacer la conversation enregistrée"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Cancella la conversazione salvata"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wyczyść zapisaną rozmowę"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Limpar a conversa guardada"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Очистить сохранённый разговор"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "清除已保存的对话"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "清除已儲存的對話"}}
+ } },
+ "Clear conversation?": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Clear conversation?"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Unterhaltung löschen?"}},
+ "es": {"stringUnit": {"state": "translated", "value": "¿Borrar conversación?"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Effacer la conversation ?"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Cancella conversazione?"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wyczyścić rozmowę?"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Limpar conversa?"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Очистить разговор?"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "清除对话?"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "清除對話?"}}
+ } },
+ "This deletes the saved conversation from this device. Coach history is your own notes, not medical advice.": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "This deletes the saved conversation from this device. Coach history is your own notes, not medical advice."}},
+ "de": {"stringUnit": {"state": "translated", "value": "Dies löscht die gespeicherte Unterhaltung von diesem Gerät. Coach-Verlauf sind deine eigenen Notizen, kein medizinischer Rat."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Esto borra la conversación guardada de este dispositivo. El historial de Coach son tus propias notas, no consejo médico."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Cela efface la conversation enregistrée de cet appareil. L'historique du Coach constitue vos propres notes, pas un avis médical."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Questo elimina la conversazione salvata da questo dispositivo. La cronologia di Coach sono i tuoi appunti, non consigli medici."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Spowoduje to usunięcie zapisanej rozmowy z tego urządzenia. Historia Coach to Twoje własne notatki, a nie porada medyczna."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Isto elimina a conversa guardada deste dispositivo. O histórico do Coach são as tuas próprias notas, não aconselhamento médico."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Это удалит сохранённый разговор с этого устройства. История Коуча — это ваши собственные заметки, а не медицинская консультация."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "这会从此设备删除已保存的对话。Coach 历史是你自己的笔记,不是医疗建议。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "這會從此裝置刪除已儲存的對話。Coach 歷史是你自己的筆記,不是醫療建議。"}}
+ } },
+ "Morning brief": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Morning brief"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Morgenbrief"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Resumen matutino"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Bref du matin"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Riepilogo mattutino"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Poranne podsumowanie"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Resumo matinal"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Утренняя сводка"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "晨间简报"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "晨間簡報"}}
+ } },
+ "A local notification with today's readiness + training plan, generated on-device each morning.": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "A local notification with today's readiness + training plan, generated on-device each morning."}},
+ "de": {"stringUnit": {"state": "translated", "value": "Eine lokale Benachrichtigung mit heutiger Bereitschaft + Trainingsplan, jeden Morgen auf dem Gerät generiert."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Una notificación local con la disponibilidad de hoy + el plan de entrenamiento, generada en el dispositivo cada mañana."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Une notification locale avec la disponibilité du jour + le plan d'entraînement, générée sur l'appareil chaque matin."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Una notifica locale con la disponibilità di oggi + il piano di allenamento, generata sul dispositivo ogni mattina."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Lokalne powiadomienie z dzisiejszą gotowością + planem treningowym, generowane na urządzeniu każdego ranka."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Uma notificação local com a disponibilidade de hoje + o plano de treino, gerada no dispositivo todas as manhãs."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Локальное уведомление с сегодняшней готовностью + планом тренировок, генерируется на устройстве каждое утро."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "一条包含今日准备度 + 训练计划的本地通知,每天早晨在设备上生成。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "一條包含今日準備度 + 訓練計劃的本地通知,每天早晨在裝置上生成。"}}
+ } },
+ "Off: nothing is generated or sent on a schedule.": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Off: nothing is generated or sent on a schedule."}},
+ "de": {"stringUnit": {"state": "translated", "value": "Aus: Es wird nichts generiert oder nach Zeitplan gesendet."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Desactivado: no se genera ni se envía nada según un horario."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Désactivé : rien n'est généré ni envoyé selon un calendrier."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Off: non viene generato né inviato nulla in base a una pianificazione."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wyłączone: nic nie jest generowane ani wysyłane według harmonogramu."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Desativado: nada é gerado ou enviado num agendamento."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Выключено: ничего не генерируется и не отправляется по расписанию."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "关闭:不会按计划生成或发送任何内容。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "關閉:不會按計劃生成或發送任何內容。"}}
+ } },
+ "Morning brief time": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Morning brief time"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Morgenbrief-Zeit"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Hora del resumen matutino"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Heure du bref du matin"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Ora del riepilogo mattutino"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Czas porannego podsumowania"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Hora do resumo matinal"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Время утренней сводки"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "晨间简报时间"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "晨間簡報時間"}}
+ } },
+ "At %@, NOOP will use your key to generate today's brief. Best-effort: %@ decides exactly when a backgrounded app wakes.": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "At %@, NOOP will use your key to generate today's brief. Best-effort: %@ decides exactly when a backgrounded app wakes."}},
+ "de": {"stringUnit": {"state": "translated", "value": "Um %@ verwendet NOOP deinen Schlüssel, um den heutigen Brief zu generieren. Best-Effort: %@ entscheidet genau, wann eine Hintergrund-App aktiviert wird."}},
+ "es": {"stringUnit": {"state": "translated", "value": "A las %@, NOOP usará tu clave para generar el resumen de hoy. Best-effort: %@ decide cuándo exactamente se activa una app en segundo plano."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "À %@, NOOP utilisera votre clé pour générer le bref du jour. Au mieux : %@ décide du moment exact où une app en arrière-plan se réveille."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Alle %@, NOOP userà la tua chiave per generare il riepilogo di oggi. Best-effort: %@ decide esattamente quando un'app in background si attiva."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "O %@ NOOP użyje Twojego klucza do wygenerowania dzisiejszego podsumowania. Best-effort: %@ decyduje, kiedy dokładnie aplikacja w tle się wybudzi."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Às %@, o NOOP usará a tua chave para gerar o resumo de hoje. Best-effort: %@ decide exatamente quando uma app em segundo plano é ativada."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "В %@ NOOP использует ваш ключ для генерации сегодняшней сводки. По возможности: %@ решает, когда именно фоновое приложение пробуждается."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "在 %@,NOOP 将使用你的密钥生成今日简报。尽力而为:%@ 决定后台应用何时唤醒。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "在 %@,NOOP 將使用你的密鑰生成今日簡報。盡力而為:%@ 決定背景應用何時喚醒。"}}
+ } },
+ "Send chart image to Gemini": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Send chart image to Gemini"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Diagrammbild an Gemini senden"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Enviar imagen del gráfico a Gemini"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Envoyer l'image du graphique à Gemini"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Invia immagine del grafico a Gemini"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wyślij obraz wykresu do Gemini"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Enviar imagem do gráfico para o Gemini"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Отправить изображение графика в Gemini"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "将图表图像发送给 Gemini"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "將圖表影像傳送給 Gemini"}}
+ } },
+ "On: a chart snapshot of your trends is sent with each question. Gemini can analyze the visual.": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "On: a chart snapshot of your trends is sent with each question. Gemini can analyze the visual."}},
+ "de": {"stringUnit": {"state": "translated", "value": "An: Bei jeder Frage wird ein Diagramm-Snapshot deiner Trends gesendet. Gemini kann das Visuelle analysieren."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Activado: se envía una captura del gráfico de tus tendencias con cada pregunta. Gemini puede analizar lo visual."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Activé : un instantané du graphique de vos tendances est envoyé avec chaque question. Gemini peut analyser le visuel."}},
+ "it": {"stringUnit": {"state": "translated", "value": "On: un'istantanea del grafico dei tuoi trend viene inviata con ogni domanda. Gemini può analizzare l'aspetto visivo."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Włączone: z każdym pytaniem wysyłany jest zrzut wykresu Twoich trendów. Gemini może przeanalizować obraz."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Ativado: uma captura do gráfico das tuas tendências é enviada com cada pergunta. O Gemini pode analisar a imagem."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Вкл: снимок графика ваших трендов отправляется с каждым вопросом. Gemini может проанализировать визуальные данные."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "开启:每次提问时会发送趋势图表快照。Gemini 可以分析视觉内容。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟:每次提問時會發送趨勢圖表快照。Gemini 可以分析視覺內容。"}}
+ } },
+ "Off: only text is sent. Enable to let Gemini see your charts.": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Off: only text is sent. Enable to let Gemini see your charts."}},
+ "de": {"stringUnit": {"state": "translated", "value": "Aus: Es wird nur Text gesendet. Aktiviere dies, damit Gemini deine Diagramme sehen kann."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Desactivado: solo se envía texto. Actívalo para que Gemini vea tus gráficos."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Désactivé : seul le texte est envoyé. Activez pour permettre à Gemini de voir vos graphiques."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Off: viene inviato solo testo. Attiva per permettere a Gemini di vedere i tuoi grafici."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wyłączone: wysyłany jest tylko tekst. Włącz, aby Gemini mógł zobaczyć Twoje wykresy."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Desativado: apenas é enviado texto. Ativa para permitir que o Gemini veja os teus gráficos."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Выкл: отправляется только текст. Включите, чтобы Gemini мог видеть ваши графики."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "关闭:仅发送文本。启用以让 Gemini 查看你的图表。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "關閉:僅發送文本。啟用以讓 Gemini 查看你的圖表。"}}
+ } },
+ "Save to Journal": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Save to Journal"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Im Tagebuch speichern"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Guardar en el diario"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Enregistrer dans le journal"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Salva nel diario"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Zapisz w dzienniku"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Guardar no diário"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Сохранить в журнал"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "保存到日志"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "儲存到日誌"}}
+ } },
+ "Follow-up prompt: %@": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Follow-up prompt: %@"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Folge-Prompt: %@"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Pregunta de seguimiento: %@"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Question de suivi : %@"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Prompt di follow-up: %@"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Prompt uzupełniający: %@"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Pergunta de seguimento: %@"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Дополнительный запрос: %@"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "后续提示:%@"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "後續提示:%@"}}
+ } },
+ "~%lld tokens": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "~%lld tokens"}},
+ "de": {"stringUnit": {"state": "translated", "value": "~%lld Token"}},
+ "es": {"stringUnit": {"state": "translated", "value": "~%lld tokens"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "~%lld jetons"}},
+ "it": {"stringUnit": {"state": "translated", "value": "~%lld token"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "~%lld tokenów"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "~%lld tokens"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "~%lld токенов"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "~%lld 个 token"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "~%lld 個 token"}}
+ } },
+ "· may exceed small context windows": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "· may exceed small context windows"}},
+ "de": {"stringUnit": {"state": "translated", "value": "· kann kleine Kontextfenster überschreiten"}},
+ "es": {"stringUnit": {"state": "translated", "value": "· puede superar ventanas de contexto pequeñas"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "· peut dépasser les petites fenêtres de contexte"}},
+ "it": {"stringUnit": {"state": "translated", "value": "· potrebbe superare le piccole finestre di contesto"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "· może przekroczyć małe okna kontekstu"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "· pode exceder janelas de contexto pequenas"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "· может превышать небольшие окна контекста"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "· 可能超出小型上下文窗口"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "· 可能超出小型上下文視窗"}}
+ } },
+ "Ask out loud": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Ask out loud"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Laut fragen"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Preguntar en voz alta"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Poser la question à voix haute"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Chiedi a voce alta"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Zapytaj na głos"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Perguntar em voz alta"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Задать вопрос вслух"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "大声提问"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "大聲提問"}}
+ } },
+ "Stop voice input": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Stop voice input"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Spracheingabe stoppen"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Detener entrada de voz"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Arrêter la saisie vocale"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Ferma input vocale"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Zatrzymaj wprowadzanie głosowe"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Parar entrada de voz"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Остановить голосовой ввод"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "停止语音输入"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "停止語音輸入"}}
+ } },
+ "Voice input": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Voice input"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Spracheingabe"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Entrada de voz"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Saisie vocale"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Input vocale"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wprowadzanie głosowe"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Entrada de voz"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Голосовой ввод"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "语音输入"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "語音輸入"}}
+ } },
+ "Coach Brief": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Coach Brief"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Coach-Brief"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Resumen del Coach"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Bref du Coach"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Riepilogo Coach"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Podsumowanie Coach"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Resumo do Coach"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Сводка Коуча"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "Coach 简报"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "Coach 簡報"}}
+ } },
+ "No brief yet": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "No brief yet"}},
+ "de": {"stringUnit": {"state": "translated", "value": "Noch kein Brief"}},
+ "es": {"stringUnit": {"state": "translated", "value": "Aún no hay resumen"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Pas encore de bref"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Nessun riepilogo ancora"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Brak podsumowania"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Ainda sem resumo"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Сводки пока нет"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "尚无简报"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "尚無簡報"}}
+ } },
+ "Enable Morning Brief in Coach settings to see today's readiness here.": {"localizations": {
+ "en": {"stringUnit": {"state": "translated", "value": "Enable Morning Brief in Coach settings to see today's readiness here."}},
+ "de": {"stringUnit": {"state": "translated", "value": "Aktiviere den Morgenbrief in den Coach-Einstellungen, um die heutige Bereitschaft hier zu sehen."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Activa el resumen matutino en los ajustes de Coach para ver la disponibilidad de hoy aquí."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Activez le bref du matin dans les réglages du Coach pour voir la disponibilité du jour ici."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Attiva il riepilogo mattutino nelle impostazioni di Coach per vedere la disponibilità di oggi qui."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Włącz poranne podsumowanie w ustawieniach Coach, aby zobaczyć tu dzisiejszą gotowość."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Ativa o resumo matinal nas definições do Coach para ver a disponibilidade de hoje aqui."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Включите утреннюю сводку в настройках Коуча, чтобы видеть сегодняшнюю готовность здесь."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "在 Coach 设置中启用晨间简报以在此处查看今日准备度。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "在 Coach 設定中啟用晨間簡報以在此處查看今日準備度。"}}
+ } },
+ "Voice input unavailable. Check microphone permission in Settings.": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "Spracheingabe nicht verfügbar. Prüfe die Mikrofonberechtigung in den Einstellungen."}},
+ "en": {"stringUnit": {"state": "translated", "value": "Voice input unavailable. Check microphone permission in Settings."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Entrada de voz no disponible. Comprueba el permiso del micrófono en Ajustes."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Saisie vocale indisponible. Vérifiez l'autorisation du microphone dans Réglages."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Input vocale non disponibile. Controlla l'autorizzazione del microfono in Impostazioni."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Wprowadzanie głosowe niedostępne. Sprawdź uprawnienie mikrofonu w Ustawieniach."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Entrada de voz indisponível. Verifica a permissão do microfone nas Definições."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Голосовой ввод недоступен. Проверьте разрешение микрофона в Настройках."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "语音输入不可用。请在“设置”中检查麦克风权限。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "語音輸入無法使用。請在「設定」中檢查麥克風權限。"}}
+ } },
+ "On-device speech not available for %@.": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "Geräteinterne Spracherkennung für %@ nicht verfügbar."}},
+ "en": {"stringUnit": {"state": "translated", "value": "On-device speech not available for %@."}},
+ "es": {"stringUnit": {"state": "translated", "value": "El reconocimiento de voz en el dispositivo no está disponible para %@."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "La reconnaissance vocale sur l'appareil n'est pas disponible pour %@."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Il riconoscimento vocale sul dispositivo non è disponibile per %@."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Rozpoznawanie mowy na urządzeniu jest niedostępne dla %@."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "O reconhecimento de voz no dispositivo não está disponível para %@."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Распознавание речи на устройстве недоступно для %@."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "设备端语音识别不支持 %@。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "裝置端語音辨識不支援 %@。"}}
+ } },
+ "Couldn't start the microphone: %@": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "Mikrofon konnte nicht gestartet werden: %@"}},
+ "en": {"stringUnit": {"state": "translated", "value": "Couldn't start the microphone: %@"}},
+ "es": {"stringUnit": {"state": "translated", "value": "No se pudo iniciar el micrófono: %@"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Impossible de démarrer le microphone : %@"}},
+ "it": {"stringUnit": {"state": "translated", "value": "Impossibile avviare il microfono: %@"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się uruchomić mikrofonu: %@"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível iniciar o microfone: %@"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Не удалось включить микрофон: %@"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法启动麦克风:%@"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法啟動麥克風:%@"}}
+ } },
+ "%lld of %lld nights": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "%lld von %lld Nächten"}},
+ "en": {"stringUnit": {"state": "translated", "value": "%lld of %lld nights"}},
+ "es": {"stringUnit": {"state": "translated", "value": "%lld de %lld noches"}},
+ "fr": {"stringUnit": {"state": "translated", "value": "%lld nuits sur %lld"}},
+ "it": {"stringUnit": {"state": "translated", "value": "%lld di %lld notti"}},
+ "pl": {"stringUnit": {"state": "translated", "value": "%lld z %lld nocy"}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld de %lld noites"}},
+ "ru": {"stringUnit": {"state": "translated", "value": "%lld из %lld ночей"}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld / %lld 晚"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld / %lld 晚"}}
+ } },
+ "%lld of the last %lld nights recorded no HRV. Check the strap is worn overnight and syncing.": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "%lld der letzten %lld Nächte haben keine HRV aufgezeichnet. Prüfe, ob der Strap nachts getragen wird und synchronisiert."}},
+ "en": {"stringUnit": {"state": "translated", "value": "%lld of the last %lld nights recorded no HRV. Check the strap is worn overnight and syncing."}},
+ "es": {"stringUnit": {"state": "translated", "value": "%lld de las últimas %lld noches no registraron HRV. Comprueba que llevas la banda por la noche y que se sincroniza."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "%lld des %lld dernières nuits n’ont enregistré aucune VFC. Vérifiez que le bracelet est porté la nuit et se synchronise."}},
+ "it": {"stringUnit": {"state": "translated", "value": "%lld delle ultime %lld notti non hanno registrato HRV. Controlla che la fascia sia indossata di notte e si sincronizzi."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "%lld z ostatnich %lld nocy nie zarejestrowało HRV. Sprawdź, czy opaska jest noszona w nocy i synchronizuje się."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld das últimas %lld noites não registaram HRV. Verifica se usas a fita à noite e se sincroniza."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "%lld из последних %lld ночей не записали ВСР. Проверьте, что ремешок надет ночью и синхронизируется."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "最近 %2$lld 晚中有 %1$lld 晚未记录 HRV。请检查夜间是否佩戴并已同步。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "最近 %2$lld 晚中有 %1$lld 晚未記錄 HRV。請檢查夜間是否配戴並已同步。"}}
+ } },
+ "Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result.": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "Leitet das absolute °C für Nächte neu ab, die das 21-Nächte-Fenster nie erreicht, mit demselben Funnel wie der Scoring-Durchlauf. Nur Auffüllen: ein gemessener Wert wird nie überschrieben. Das Ergebnis steht im Strap-Log."}},
+ "en": {"stringUnit": {"state": "translated", "value": "Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result."}},
+ "es": {"stringUnit": {"state": "translated", "value": "Vuelve a derivar el °C absoluto de las noches que la ventana de 21 noches nunca alcanza, con el mismo embudo que la pasada de puntuación. Solo rellena: nunca sobrescribe un valor medido. Consulta el resultado en el registro de la banda."}},
+ "fr": {"stringUnit": {"state": "translated", "value": "Recalcule le °C absolu des nuits que la fenêtre de 21 nuits n’atteint jamais, avec le même entonnoir que la passe de calcul. Remplissage seul : une valeur mesurée n’est jamais écrasée. Le résultat s’affiche dans le journal du bracelet."}},
+ "it": {"stringUnit": {"state": "translated", "value": "Ricalcola il °C assoluto per le notti che la finestra di 21 notti non raggiunge mai, con lo stesso funnel della passata di calcolo. Solo riempimento: non sovrascrive mai un valore misurato. Il risultato è nel log della fascia."}},
+ "pl": {"stringUnit": {"state": "translated", "value": "Ponownie wylicza bezwzględne °C dla nocy, których nie obejmuje okno 21 nocy, tym samym lejkiem co przebieg oceniający. Tylko uzupełnia: nigdy nie nadpisuje zmierzonej wartości. Wynik znajdziesz w dzienniku opaski."}},
+ "pt-PT": {"stringUnit": {"state": "translated", "value": "Volta a derivar o °C absoluto das noites que a janela de 21 noites nunca alcança, com o mesmo funil da passagem de pontuação. Apenas preenche: nunca substitui um valor medido. Vê o resultado no registo da fita."}},
+ "ru": {"stringUnit": {"state": "translated", "value": "Пересчитывает абсолютные °C для ночей, до которых не доходит окно в 21 ночь, тем же алгоритмом, что и обычный расчёт. Только заполняет: измеренное значение никогда не перезаписывается. Результат смотрите в журнале ремешка."}},
+ "zh-Hans": {"stringUnit": {"state": "translated", "value": "用与评分流程相同的漏斗,为 21 晚窗口未覆盖的夜晚重新推导绝对温度(°C)。仅填充:绝不覆盖已测量的值。结果见腕带日志。"}},
+ "zh-Hant": {"stringUnit": {"state": "translated", "value": "以與評分流程相同的漏斗,為 21 晚視窗未涵蓋的夜晚重新推導絕對溫度(°C)。僅填充:絕不覆寫已測量的值。結果見腕帶日誌。"}}
} }
},
"version": "1.0"
-}
+}
\ No newline at end of file
diff --git a/Strand/Screens/AddDeviceWizard.swift b/Strand/Screens/AddDeviceWizard.swift
index d885560651..6f70fc91dc 100644
--- a/Strand/Screens/AddDeviceWizard.swift
+++ b/Strand/Screens/AddDeviceWizard.swift
@@ -327,7 +327,7 @@ struct AddDeviceWizard: View {
VStack(alignment: .leading, spacing: 10) {
typeRow(.whoop5mg, icon: "applewatch.side.right",
title: "WHOOP 5.0 / MG",
- subtitle: String(localized: "Newer WHOOP band, experimental in NOOP"))
+ subtitle: String(localized: "Newer WHOOP band with live data and history sync"))
typeRow(.whoop4, icon: "applewatch.side.right",
title: "WHOOP 4.0",
subtitle: String(localized: "NOOP's primary, fully-supported band"))
@@ -1430,7 +1430,7 @@ struct AddDeviceWizard: View {
Image(systemName: "flask")
.foregroundStyle(StrandPalette.statusWarning)
.accessibilityHidden(true)
- Text("WHOOP 5.0 / MG support is newer and still experimental in NOOP.")
+ Text("WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.")
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.statusWarning)
.fixedSize(horizontal: false, vertical: true)
diff --git a/Strand/Screens/AutoWorkoutCard.swift b/Strand/Screens/AutoWorkoutCard.swift
index 1f885a9053..8938320204 100644
--- a/Strand/Screens/AutoWorkoutCard.swift
+++ b/Strand/Screens/AutoWorkoutCard.swift
@@ -131,13 +131,9 @@ struct AutoWorkoutCard: View {
}
/// HH:mm in the user's locale/timezone.
- private static let timeFmt: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.timeStyle = .short
- f.dateStyle = .none
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() }
/// Localized medium date ("Jun 23, 2026") for a bout older than yesterday.
private static let dateFmt: DateFormatter = {
diff --git a/Strand/Screens/BackupSyncView.swift b/Strand/Screens/BackupSyncView.swift
index 599aa35942..9abcd24bfb 100644
--- a/Strand/Screens/BackupSyncView.swift
+++ b/Strand/Screens/BackupSyncView.swift
@@ -270,7 +270,14 @@ struct BackupSyncView: View {
alertMessage = String(localized: "Fully quit and reopen NOOP to load it.")
case .failure(let m):
alertTitle = String(localized: "Restore problem"); alertMessage = m
- case .cancelled, .exported:
+ case .restoreTooLarge(let name, let limit):
+ // #1807: recoverable, but not from here — this view restores a snapshot directly and
+ // has no confirm step to hang the override on. Point at the path that does, rather
+ // than leaving the user with a refusal and nowhere to go.
+ let cap = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file)
+ alertTitle = String(localized: "Backup problem")
+ alertMessage = String(localized: "\(name) is larger than the \(cap) NOOP restores without asking. You can still restore it from Settings → Backup & restore → Import, which will ask you to confirm.")
+ case .cancelled, .exported, .exportedOversize:
alertTitle = String(localized: "Restore problem"); alertMessage = String(localized: "Couldn't restore that backup.")
}
showAlert = true
@@ -287,6 +294,8 @@ struct BackupSyncView: View {
private func absoluteTime(_ ms: Int) -> String {
let f = DateFormatter()
+ // #1821: localized DATE style untouched; only the hour cycle is the reader's.
+ f.locale = AppClock.formattingLocale
f.dateStyle = .medium
f.timeStyle = .short
return f.string(from: Date(timeIntervalSince1970: Double(ms) / 1000.0))
@@ -355,6 +364,9 @@ private struct RestorePickerSheet: View {
private func absoluteTime(_ ms: Int) -> String {
let f = DateFormatter()
+ // #1821: localized DATE style untouched; only the hour cycle is the reader's. Second copy of
+ // this helper in the file - a single-shot replace fixed only the first, which the sweep caught.
+ f.locale = AppClock.formattingLocale
f.dateStyle = .medium
f.timeStyle = .short
return f.string(from: Date(timeIntervalSince1970: Double(ms) / 1000.0))
diff --git a/Strand/Screens/BodyClockDialCard.swift b/Strand/Screens/BodyClockDialCard.swift
new file mode 100644
index 0000000000..28e35ebd4d
--- /dev/null
+++ b/Strand/Screens/BodyClockDialCard.swift
@@ -0,0 +1,257 @@
+import SwiftUI
+import StrandAnalytics
+import StrandDesign
+
+/// A 24 h dial comparing the night actually slept against the chronotype-ideal window (#1680).
+///
+/// Two concentric arcs on one ring: the outer is where the body clock wanted the night, the inner is
+/// where it happened. Overlap is the whole message — the card exists so "was last night's timing right"
+/// is answerable at a glance, which the existing text-only `BodyClockCard` on Health cannot do.
+///
+/// VOCABULARY. The caption measures `sleepWindowOffsetHours` — the distance between the two ARCS DRAWN —
+/// and NOT `offsetVsScheduleMinutes`, which compares the clock to the wearer's habitual schedule and is
+/// what the Health card already reports. The two disagree exactly when someone keeps a consistent
+/// schedule that does not suit their clock, and that is the case this dial exists to show, so captioning
+/// it with the other number would contradict the picture.
+///
+/// Nothing here computes a metric: the window, the offset and the chronotype all come from
+/// `CircadianEngine`, byte-identical with the Kotlin twin. Only the drawing is per-platform.
+struct BodyClockDialCard: View {
+ let estimate: CircadianEngine.PhaseEstimate
+ /// The night's own bed/wake clock hours (0..<24, fractional), from the scored session.
+ let actualBedHour: Double
+ let actualWakeHour: Double
+
+ // One hue for both arcs, told apart by dash and weight rather than by a second colour. Two blues
+ // competed with the background image; a single legible one plus a dashed, lighter reference does not.
+ private var hue: Color { StrandPalette.restLine }
+
+ /// The night's length, taken the long way round the clock when it crosses midnight.
+ private var durationHours: Double {
+ let d = (actualWakeHour - actualBedHour).truncatingRemainder(dividingBy: 24)
+ return d <= 0 ? d + 24 : d
+ }
+
+ private var ideal: (bedHour: Double, wakeHour: Double)? {
+ CircadianEngine.idealSleepWindow(tempMinHour: estimate.tempMinHour, durationHours: durationHours)
+ }
+
+ private var offsetHours: Double {
+ CircadianEngine.sleepWindowOffsetHours(tempMinHour: estimate.tempMinHour,
+ actualWakeHour: actualWakeHour)
+ }
+
+ var body: some View {
+ // `ideal` is nil exactly when the night's length is non-positive or a full day — the same input
+ // that makes `sweep` wrap to 24 h and draw the actual arc as a complete ring. Rendering a full
+ // circle with no ideal arc beside it would state something false about the night, so the card
+ // stands down instead. Twin of the Kotlin guard.
+ if ideal != nil {
+ NoopCard(tint: hue) {
+ VStack(alignment: .leading, spacing: NoopMetrics.gap) {
+ header
+ dial
+ .frame(maxWidth: .infinity)
+ .accessibilityElement(children: .ignore)
+ // Label only. The verdict is the caption Text below, a separate element, so giving
+ // the dial the same string as its VALUE made VoiceOver announce it twice.
+ .accessibilityLabel(Text("Body clock dial"))
+ legend
+ Text(alignmentText)
+ .font(StrandFont.title2)
+ .foregroundStyle(StrandPalette.textPrimary)
+ .fixedSize(horizontal: false, vertical: true)
+ if let chronotype = CircadianEngine.chronotype(estimate) {
+ Text(chronotypeText(chronotype))
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ }
+ }
+
+ private var header: some View {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Body clock").strandOverline()
+ Text("Last night against your clock")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ }
+ Spacer()
+ }
+ }
+
+ /// Midnight at the top, clocking round to the right — the orientation every 24 h dial uses, so the
+ /// ring reads without a legend. SwiftUI's zero angle is at 3 o'clock, hence the −90.
+ private func angle(_ hour: Double) -> Angle { .degrees(hour / 24 * 360 - 90) }
+
+ /// Sweep from `from` to `to` going clockwise, always positive so an arc crossing midnight still draws.
+ private func sweep(_ from: Double, _ to: Double) -> Double {
+ let d = (to - from).truncatingRemainder(dividingBy: 24)
+ return d <= 0 ? d + 24 : d
+ }
+
+ /// The ring geometry, resolved once. Explicitly typed and hoisted out of the drawing closure: an
+ /// 84-line `Canvas` body of inferred `CGFloat` arithmetic exceeded the Swift type-checker's budget
+ /// and failed the macOS build outright ("unable to type-check this expression in reasonable time"),
+ /// which only app-build compiles — the same trap `DevicesView`'s gates hit.
+ private struct DialGeometry {
+ let centre: CGPoint
+ let outer: CGFloat
+ let inner: CGFloat
+
+ init(size: CGSize) {
+ let side: CGFloat = min(size.width, size.height)
+ centre = CGPoint(x: size.width / 2, y: size.height / 2)
+ outer = side / 2 - 10
+ inner = (side / 2 - 10) - 16
+ }
+ }
+
+ private var dial: some View {
+ Canvas { ctx, size in
+ let g = DialGeometry(size: size)
+ drawTrack(ctx, g)
+ drawTicks(ctx, g)
+ drawArcs(ctx, g)
+ drawOnset(ctx, g)
+ }
+ .frame(height: 170)
+ }
+
+ /// A full-circle TRACK under the night arc, the same idiom `RecoveryRing` uses: a faint
+ /// `surfaceInset` band with the live arc drawn on top. A one-point hairline left the dial reading as
+ /// a thin wireframe against a busy background; a real track gives the ring presence and makes the
+ /// highlighted segment obvious as a portion of a whole day. The hairline stays at the reference
+ /// radius so the dashed arc has a circle to belong to.
+ private func drawTrack(_ ctx: GraphicsContext, _ g: DialGeometry) {
+ let trackRect = CGRect(x: g.centre.x - g.inner, y: g.centre.y - g.inner,
+ width: g.inner * 2, height: g.inner * 2)
+ ctx.stroke(Path(ellipseIn: trackRect), with: .color(StrandPalette.surfaceInset),
+ style: StrokeStyle(lineWidth: 9, lineCap: .round))
+ let rimRect = CGRect(x: g.centre.x - g.outer, y: g.centre.y - g.outer,
+ width: g.outer * 2, height: g.outer * 2)
+ ctx.stroke(Path(ellipseIn: rimRect), with: .color(StrandPalette.hairline), lineWidth: 1)
+ }
+
+ /// Six-hourly ticks, with MIDNIGHT drawn longer and brighter. Four identical marks at 90 degrees
+ /// orient nothing — the ring is symmetric under rotation as far as they are concerned, so a reader
+ /// cannot tell midnight from noon and the arcs become unplaceable. One distinguished mark anchors the
+ /// whole dial, and does it without text, keeping the card clear of a 12-versus-24-hour format question.
+ private func drawTicks(_ ctx: GraphicsContext, _ g: DialGeometry) {
+ for tick in stride(from: 0.0, to: 24.0, by: 6.0) {
+ let isMidnight: Bool = tick == 0
+ let a: Double = angle(tick).radians
+ let len: CGFloat = isMidnight ? 9 : 4
+ let cosA: CGFloat = cos(a)
+ let sinA: CGFloat = sin(a)
+ var p = Path()
+ p.move(to: CGPoint(x: g.centre.x + cosA * (g.outer - len),
+ y: g.centre.y + sinA * (g.outer - len)))
+ p.addLine(to: CGPoint(x: g.centre.x + cosA * g.outer, y: g.centre.y + sinA * g.outer))
+ let tint: Color = isMidnight ? StrandPalette.textSecondary
+ : StrandPalette.textTertiary.opacity(0.5)
+ ctx.stroke(p, with: .color(tint), lineWidth: isMidnight ? 1.5 : 1)
+ }
+ }
+
+ /// The two arcs differ by PATTERN as well as weight. Opacity alone was the first cut and it does not
+ /// survive the card being translucent over a custom background image — the reference arc washed out
+ /// to near-invisible on a real device, losing the comparison the card exists for. BUTT caps on the
+ /// dashed stroke: a round cap adds lineWidth/2 at EACH end of EVERY dash, so at a 7 pt stroke a
+ /// [2, 7] dash renders as 9 pt of ink with a 0 pt gap — a line that looks solid while claiming to be
+ /// dashed, the one outcome this must not produce.
+ private func drawArcs(_ ctx: GraphicsContext, _ g: DialGeometry) {
+ if let ideal {
+ strokeArc(ctx, g, radius: g.outer, from: ideal.bedHour, to: ideal.wakeHour,
+ colour: hue.opacity(0.55), width: 7, dashed: true)
+ }
+ strokeArc(ctx, g, radius: g.inner, from: actualBedHour, to: actualWakeHour,
+ colour: hue, width: 9, dashed: false)
+ }
+
+ private func strokeArc(_ ctx: GraphicsContext, _ g: DialGeometry, radius: CGFloat,
+ from: Double, to: Double, colour: Color, width: CGFloat, dashed: Bool) {
+ let startAngle: Angle = angle(from)
+ let sweepDegrees: Double = sweep(from, to) / 24 * 360
+ var p = Path()
+ p.addArc(center: g.centre, radius: radius, startAngle: startAngle,
+ endAngle: .degrees(startAngle.degrees + sweepDegrees), clockwise: false)
+ let dash: [CGFloat] = dashed ? [3, 5] : []
+ ctx.stroke(p, with: .color(colour),
+ style: StrokeStyle(lineWidth: width, lineCap: dashed ? .butt : .round, dash: dash))
+ }
+
+ /// A bed at sleep ONSET. Without it the night arc has two indistinguishable ends and the reader must
+ /// work out which way round the day runs before the picture means anything — the marker turns
+ /// "somewhere in this band" into "it started here". Drawn INTO A RECT, not at a point: `draw(_:at:)`
+ /// renders an SF Symbol at its intrinsic size, which follows the environment font and would not match
+ /// the Kotlin twin's fixed 14 dp glyph.
+ private func drawOnset(_ ctx: GraphicsContext, _ g: DialGeometry) {
+ var bed = ctx.resolve(Image(systemName: "bed.double.fill"))
+ bed.shading = .color(hue)
+ let onset: Double = angle(actualBedHour).radians
+ let glyph: CGFloat = 14
+ let x: CGFloat = g.centre.x + cos(onset) * g.inner - glyph / 2
+ let y: CGFloat = g.centre.y + sin(onset) * g.inner - glyph / 2
+ ctx.draw(bed, in: CGRect(x: x, y: y, width: glyph, height: glyph))
+ }
+
+ /// Which arc is which. Without this the card shows two bands and no way to tell them apart —
+ /// "outer means ideal" is an arbitrary choice, not something a reader can infer. The swatches are
+ /// drawn with the SAME stroke style as the arcs so the mapping cannot drift apart from the drawing.
+ private var legend: some View {
+ HStack(spacing: 14) {
+ legendItem(colour: hue, dashed: false, label: String(localized: "Last night"))
+ legendItem(colour: hue.opacity(0.55), dashed: true,
+ label: String(localized: "Your clock"))
+ Spacer()
+ }
+ .accessibilityHidden(true) // the caption below already states the comparison in words
+ }
+
+ private func legendItem(colour: Color, dashed: Bool, label: String) -> some View {
+ HStack(spacing: 5) {
+ Canvas { ctx, size in
+ var p = Path()
+ p.move(to: CGPoint(x: 0, y: size.height / 2))
+ p.addLine(to: CGPoint(x: size.width, y: size.height / 2))
+ ctx.stroke(p, with: .color(colour),
+ style: StrokeStyle(lineWidth: 3, lineCap: dashed ? .butt : .round,
+ dash: dashed ? [2, 3] : []))
+ }
+ .frame(width: 18, height: 4)
+ Text(label)
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ }
+ }
+
+ /// Rounded to five minutes: the underlying phase is an activity fit, so a to-the-minute caption would
+ /// imply a precision the estimate does not carry.
+ private var alignmentText: String {
+ let minutes = Int((offsetHours * 60 / 5).rounded()) * 5
+ if abs(minutes) < 30 { return String(localized: "In sync with your body clock") }
+ let hours = abs(Double(minutes)) / 60
+ // Locale-formatted, NOT String(format:). That is C-locale, so it prints "1.5 h" for a German
+ // reader while the Android twin's String.format prints "1,5 h" from the default locale — the two
+ // platforms disagreeing on a decimal separator in the same sentence.
+ let amount = hours >= 1
+ ? "\(hours.formatted(.number.precision(.fractionLength(1)))) h"
+ : "\(abs(minutes)) min"
+ return minutes > 0
+ ? String(localized: "\(amount) later than your body clock")
+ : String(localized: "\(amount) earlier than your body clock")
+ }
+
+ private func chronotypeText(_ c: CircadianEngine.Chronotype) -> String {
+ switch c {
+ case .morning: return String(localized: "Morning type")
+ case .intermediate: return String(localized: "Intermediate type")
+ case .evening: return String(localized: "Evening type")
+ }
+ }
+}
diff --git a/Strand/Screens/CaffeineLogCard.swift b/Strand/Screens/CaffeineLogCard.swift
index 4cdd835796..383728f2a6 100644
--- a/Strand/Screens/CaffeineLogCard.swift
+++ b/Strand/Screens/CaffeineLogCard.swift
@@ -201,12 +201,9 @@ struct CaffeineLogCard: View {
return Self.cutoffTimeFormatter.string(from: date)
}
- private static let cutoffTimeFormatter: DateFormatter = {
- let f = DateFormatter()
- f.timeStyle = .short
- f.dateStyle = .none
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var cutoffTimeFormatter: DateFormatter { AppClock.hourMinuteFormatter() }
// MARK: - Active hint
@@ -335,10 +332,7 @@ struct CaffeineLogCard: View {
.buttonStyle(.plain)
}
- private static let timeFormatter: DateFormatter = {
- let f = DateFormatter()
- f.timeStyle = .short
- f.dateStyle = .none
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var timeFormatter: DateFormatter { AppClock.hourMinuteFormatter() }
}
diff --git a/Strand/Screens/CoachLauncherSheet.swift b/Strand/Screens/CoachLauncherSheet.swift
new file mode 100644
index 0000000000..e43ec758e6
--- /dev/null
+++ b/Strand/Screens/CoachLauncherSheet.swift
@@ -0,0 +1,146 @@
+import SwiftUI
+import StrandDesign
+
+/// The compact Coach launcher opened from the optional Today card (#1862).
+///
+/// Coach is otherwise reachable only through More/Insights, which makes it easy to miss and means
+/// leaving Today to try it. This is the shortcut — and deliberately ONLY a shortcut.
+///
+/// It owns no send, stream, error or consent surface of its own. Picking a suggestion or submitting the
+/// composer hands the question to `AICoachEngine.pendingPrompt` and routes to `CoachView`, which already
+/// has all of that. A second chat UI would drift from the first, and the issue explicitly defers the
+/// persistent-workspace redesign (threads, retention, backup policy) to a follow-up.
+///
+/// NO PROVIDER REQUEST IS MADE BY OPENING THIS. Everything shown is local: `isConfigured` reads the
+/// stored key, and the prompts are static copy. The first network call still happens where it always
+/// did — inside `AICoachEngine.send`, after an explicit user action.
+struct CoachLauncherSheet: View {
+ @EnvironmentObject var coach: AICoachEngine
+ @EnvironmentObject var router: NavRouter
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var draft = ""
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ if coach.isConfigured {
+ configured
+ } else {
+ unconfigured
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .background(StrandPalette.surfaceBase.ignoresSafeArea())
+ .navigationTitle(Text("Coach"))
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ }
+ #if os(iOS)
+ .presentationDetents([.medium, .large])
+ #endif
+ }
+
+ // MARK: Configured — suggestions + a compact composer
+
+ @ViewBuilder
+ private var configured: some View {
+ Text("Ask about your charge, effort, rest and workouts, grounded in your own numbers.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+
+ // Deliberately no "Try asking" heading: the line above already frames the list, and a new
+ // literal would be the only string in this card needing translation into ten locales. The whole
+ // launcher reuses copy the Coach screen already ships.
+
+ ForEach(CoachPrompts.suggestions, id: \.self) { prompt in
+ Button { hand(off: prompt) } label: {
+ Text(prompt)
+ .font(StrandFont.caption)
+ .foregroundStyle(StrandPalette.textPrimary)
+ .padding(.horizontal, 12).padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(Text("Suggested prompt: \(prompt)"))
+ }
+
+ HStack(spacing: 8) {
+ TextField("Ask your coach…", text: $draft, axis: .vertical)
+ .textFieldStyle(.plain)
+ .lineLimit(1...3)
+ .padding(.horizontal, 12).padding(.vertical, 9)
+ .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius))
+ .onSubmit { submitDraft() }
+ Button {
+ submitDraft()
+ } label: {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 26, weight: .semibold))
+ .foregroundStyle(StrandPalette.accent)
+ }
+ .buttonStyle(.plain)
+ .disabled(trimmedDraft.isEmpty)
+ .accessibilityLabel(Text("Send"))
+ }
+ .padding(.top, 4)
+ }
+
+ // MARK: Not configured — explain the opt-in and route to the existing setup
+
+ @ViewBuilder
+ private var unconfigured: some View {
+ // The SAME explanation the Coach screen shows, so the bring-your-own-key model is described
+ // once. The button routes to that screen, which stays the only place a key is entered.
+ Text("Coach uses your own API key. Pick a provider, paste a key, and choose a model. Your key is stored securely in the Keychain and never leaves \(Platform.deviceNounPhrase) except as the request you make.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+
+ Button {
+ dismiss()
+ router.openCoach()
+ } label: {
+ Text("Connect a provider")
+ .font(StrandFont.caption)
+ .foregroundStyle(StrandPalette.textPrimary)
+ .padding(.horizontal, 14).padding(.vertical, 10)
+ .frame(maxWidth: .infinity)
+ .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ // MARK: Handoff
+
+ private var trimmedDraft: String {
+ draft.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private func submitDraft() {
+ let text = trimmedDraft
+ guard !text.isEmpty else { return }
+ hand(off: text)
+ }
+
+ /// Dismiss, park the question, and open Coach. Deliberately NOT `coach.send` from here: the launcher
+ /// never performs a provider request, so a user who opens this sheet and changes their mind has cost
+ /// nothing and sent nothing.
+ private func hand(off prompt: String) {
+ coach.pendingPrompt = prompt
+ draft = ""
+ dismiss()
+ router.openCoach()
+ }
+}
diff --git a/Strand/Screens/CoachPrompts.swift b/Strand/Screens/CoachPrompts.swift
new file mode 100644
index 0000000000..bcbe96388e
--- /dev/null
+++ b/Strand/Screens/CoachPrompts.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+/// The suggested questions offered when a Coach thread is empty (#1862).
+///
+/// Extracted from `CoachView` so the Today launcher sheet and the full screen offer the SAME four
+/// prompts. Two hardcoded lists would have drifted the moment either was edited, and the launcher's
+/// whole purpose is to be a shortcut into the screen rather than a second, subtly different Coach.
+///
+/// The strings are unchanged and keep their existing String Catalog keys, so nothing needs retranslating.
+enum CoachPrompts {
+ static let suggestions: [String] = [
+ String(localized: "How's my charge trending?"),
+ String(localized: "What should today's training look like?"),
+ String(localized: "Analyse my sleep"),
+ String(localized: "Why am I run down?"),
+ ]
+}
diff --git a/Strand/Screens/CoachView.swift b/Strand/Screens/CoachView.swift
index e84e109347..812b2048a3 100644
--- a/Strand/Screens/CoachView.swift
+++ b/Strand/Screens/CoachView.swift
@@ -14,9 +14,15 @@ import StrandDesign
/// `sending`, `errorText`, `setKey(_:)`, `clearKey()`, and `send(_:)`.
struct CoachView: View {
@EnvironmentObject var coach: AICoachEngine
+ /// K8: used by "Save to Journal" — saves the coach advice as a journal entry with the text
+ /// in the notes field, so it appears alongside other journal entries in Insights.
+ @EnvironmentObject var repo: Repository
/// Draft text in the composer (the question being typed).
- @State private var draft: String = ""
+ /// K15: the composer draft is persisted to UserDefaults so it survives an app relaunch.
+ /// Restored on first appear, saved on every change. Keyed identically to the Android twin.
+ private static let draftKey = "coach.composerDraft"
+ @State private var draft: String = UserDefaults.standard.string(forKey: "coach.composerDraft") ?? ""
/// Pending key text in the setup card (never persisted here, handed to `setKey`).
@State private var keyDraft: String = ""
/// Whether the model selector is in free-text "Custom…" mode.
@@ -31,15 +37,27 @@ struct CoachView: View {
@State private var promptDraft: String = ""
@FocusState private var composerFocused: Bool
+ // K5: scheduled morning-brief notification settings (CoachBriefScheduler).
+ @State private var briefEnabled: Bool = CoachBriefScheduler.isEnabled
+ @State private var briefMinutes: Int = CoachBriefScheduler.timeMinutes
+ @State private var briefGenerating = false
+ @State private var briefStatus: String?
+ /// K2: confirmation gate for the destructive "Clear conversation" toolbar action.
+ @State private var showClearConfirm = false
+
+ // K4: on-device voice input for the composer (iOS only). macOS gets a no-op stub via
+ // `#if os(iOS)` guards — the shared file keeps compiling for both targets.
+ #if os(iOS)
+ @StateObject private var voiceInput = CoachVoiceInput()
+ #endif
+
/// Sentinel tag for the "Custom…" entry in the model Picker.
private let customModelTag = "__custom__"
- private let suggestions = [
- String(localized: "How's my charge trending?"),
- String(localized: "What should today's training look like?"),
- String(localized: "Analyse my sleep"),
- String(localized: "Why am I run down?"),
- ]
+ /// Contextual suggestion chips, derived from today's bands by `AICoachEngine.suggestions`
+ /// (→ `CoachSuggestions`). Falls back to a stable generic set when there is no data. Recomputed
+ /// on each body evaluation so a fresh sync immediately updates the chips.
+ private var suggestions: [String] { coach.suggestions }
var body: some View {
ScreenScaffold(title: "Coach",
@@ -54,13 +72,27 @@ struct CoachView: View {
// v5: a SECOND opt-in, only meaningful once data access is on, folds a summary of the
// new on-device signals (your strongest patterns + Lab Book) into the coach context.
if coach.dataConsent { onDeviceSignalsBar }
+ if coach.dataConsent && coach.provider == .gemini { multimodalChartBar }
systemPromptBar
+ morningBriefBar
transcript
if let error = coach.errorText, !error.isEmpty {
errorBanner(error)
}
- suggestionChips
+ // K7: show follow-up chips after each assistant reply (when the transcript is
+ // non-empty and the last message is from the assistant and not mid-send);
+ // otherwise show the initial contextual chips.
+ if showFollowUpChips {
+ followUpChips
+ } else {
+ suggestionChips
+ }
composer
+ // K12: show a rough token estimate when the draft is non-empty.
+ if !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ let tokens = coach.estimatedTokens(forDraft: draft) {
+ tokenEstimateBar(tokens)
+ }
privacyFootnote
} else {
setupCard
@@ -68,6 +100,17 @@ struct CoachView: View {
}
.toolbar {
if coach.isConfigured {
+ // K2: wipe the persisted + in-memory conversation. Confirmed, since it's destructive.
+ ToolbarItem {
+ Button(role: .destructive) {
+ showClearConfirm = true
+ } label: {
+ Label("Clear conversation", systemImage: "trash")
+ }
+ .help("Clear the saved conversation")
+ .accessibilityLabel("Clear conversation")
+ .disabled(coach.messages.isEmpty)
+ }
ToolbarItem {
Button(role: .destructive) {
coach.disconnect()
@@ -80,7 +123,142 @@ struct CoachView: View {
}
}
}
- .task(id: coach.dataConsent) { await coach.startBriefIfNeeded() }
+ .confirmationDialog(
+ "Clear conversation?",
+ isPresented: $showClearConfirm,
+ titleVisibility: .visible
+ ) {
+ Button("Clear", role: .destructive) { coach.clearConversation() }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This deletes the saved conversation from this device. Coach history is your own notes, not medical advice.")
+ }
+ // K2 + K5 ordering matters and every step gates on an EMPTY transcript, so this is ONE `.task`
+ // running sequentially (separate `.task`s can interleave at their await points on the same
+ // actor): restore whatever the prior launch persisted, THEN surface a brief the scheduled
+ // notification already generated (if any), THEN the interactive first-open brief — so
+ // `startBriefIfNeeded` only ever runs over the network when BOTH of the above left the
+ // transcript genuinely empty.
+ .task {
+ await coach.loadPersistedMessagesIfNeeded()
+ if let stored = CoachBriefScheduler.consumeStoredBrief() {
+ coach.surfaceScheduledBrief(stored)
+ }
+ CoachBriefScheduler.activateIfEnabled { await coach.generateBrief() }
+ await coach.startBriefIfNeeded()
+ }
+ // #1862: a question handed over by the Today launcher sheet. Cleared BEFORE sending so a view
+ // rebuild mid-flight cannot send it twice, and gated on `isConfigured` so an unconfigured handoff
+ // (which the launcher does not produce, but a future caller might) degrades to showing setup
+ // rather than a failed request.
+ .task(id: coach.pendingPrompt) {
+ guard let prompt = coach.pendingPrompt, !prompt.isEmpty else { return }
+ coach.pendingPrompt = nil
+ guard coach.isConfigured else { return }
+ await coach.send(prompt)
+ }
+ // K15: persist the composer draft so it survives an app relaunch.
+ .onChangeCompat(of: draft) { newValue in
+ UserDefaults.standard.set(newValue, forKey: Self.draftKey)
+ }
+ // K14: haptic feedback when a reply arrives (sending goes true → false).
+ .onChangeCompat(of: coach.sending) { isSending in
+ if !isSending && !coach.messages.isEmpty {
+ triggerReplyHaptic()
+ }
+ }
+ // A consent toggle AFTER the initial load re-checks the brief (the original `.task(id:)`
+ // behaviour); the guard inside `startBriefIfNeeded` (messages.isEmpty) keeps this a no-op once
+ // a conversation exists.
+ .onChangeCompat(of: coach.dataConsent) { _ in
+ Task { await coach.startBriefIfNeeded() }
+ }
+ }
+
+ /// K5: the scheduled morning-brief notification settings — enable toggle, time-of-day picker, and an
+ /// explicit "Generate now" button. Mirrors the `ScheduledDebugExport` settings row shape (TestCentreView).
+ private var morningBriefBar: some View {
+ NoopCard(padding: 14, tint: StrandPalette.chargeColor) {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 10) {
+ Image(systemName: briefEnabled ? "sunrise.fill" : "sunrise")
+ .foregroundStyle(briefEnabled ? StrandPalette.accent : StrandPalette.textTertiary)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Morning brief").font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary)
+ Text(briefEnabled
+ ? "A local notification with today's readiness + training plan, generated on-device each morning."
+ : "Off: nothing is generated or sent on a schedule.")
+ .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ Toggle("", isOn: $briefEnabled)
+ .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent)
+ .accessibilityLabel("Morning brief")
+ }
+ .onChangeCompat(of: briefEnabled) { on in
+ CoachBriefScheduler.setEnabled(on, generateBrief: { await coach.generateBrief() }) { outcome in
+ if outcome == .denied {
+ briefEnabled = false
+ briefStatus = "Notifications are off for NOOP — enable them in Settings first."
+ }
+ }
+ }
+
+ if briefEnabled {
+ Divider().overlay(StrandPalette.hairline)
+ HStack {
+ Text("Time").font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary)
+ Spacer()
+ DatePicker("", selection: briefTimeBinding, displayedComponents: .hourAndMinute)
+ .labelsHidden()
+ .accessibilityLabel("Morning brief time")
+ }
+ Text("At \(Platform.deviceNounPhrase == "Mac" ? "this time" : "or soon after"), NOOP will use your key to generate today's brief. Best-effort: \(Platform.deviceNounPhrase) decides exactly when a backgrounded app wakes.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ NoopButton(briefGenerating ? "Generating…" : "Generate now", systemImage: "sparkles", kind: .secondary) {
+ generateBriefNow()
+ }
+ .disabled(briefGenerating)
+ if let briefStatus {
+ Text(briefStatus).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary)
+ }
+ }
+ }
+ }
+ }
+
+ private var briefTimeBinding: Binding {
+ Binding(
+ get: {
+ var c = DateComponents()
+ c.hour = briefMinutes / 60
+ c.minute = briefMinutes % 60
+ return Calendar.current.date(from: c) ?? Date()
+ },
+ set: { date in
+ let c = Calendar.current.dateComponents([.hour, .minute], from: date)
+ let m = (c.hour ?? 7) * 60 + (c.minute ?? 0)
+ briefMinutes = m
+ CoachBriefScheduler.setTimeMinutes(m, generateBrief: { await coach.generateBrief() })
+ }
+ )
+ }
+
+ private func generateBriefNow() {
+ Task {
+ briefGenerating = true
+ briefStatus = nil
+ defer { briefGenerating = false }
+ let text = await CoachBriefScheduler.generateNow { await coach.generateBrief() }
+ if let text {
+ coach.appendGeneratedBrief(text)
+ } else {
+ briefStatus = "Couldn't generate a brief right now — check your key and data access."
+ }
+ }
}
/// Explicit, revocable permission for the coach to read & send the user's data. Off by default.
@@ -133,6 +311,31 @@ struct CoachView: View {
}
}
+ /// K11: Third opt-in — send a chart image alongside the text when using Gemini's multimodal
+ /// API. Only shown when the provider is Gemini. OFF by default.
+ private var multimodalChartBar: some View {
+ NoopCard(padding: 14, tint: StrandPalette.chargeColor) {
+ HStack(spacing: 10) {
+ Image(systemName: coach.multimodalChartEnabled ? "photo.badge.checkmark" : "photo")
+ .foregroundStyle(coach.multimodalChartEnabled ? StrandPalette.accent : StrandPalette.textTertiary)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Send chart image to Gemini")
+ .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary)
+ Text(coach.multimodalChartEnabled
+ ? "On: a chart snapshot of your trends is sent with each question. Gemini can analyze the visual."
+ : "Off: only text is sent. Enable to let Gemini see your charts.")
+ .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ Toggle("", isOn: $coach.multimodalChartEnabled)
+ .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent)
+ .accessibilityLabel("Send chart image to Gemini")
+ }
+ }
+ }
+
/// Editable system prompt, the instructions that frame the coach. Collapsed by default; expanding
/// reveals a TextEditor bound to the engine (edits persist to UserDefaults and take effect on the
/// next message) plus a Reset-to-default control. Lives inline in the existing settings, NOT a modal.
@@ -478,6 +681,7 @@ struct CoachView: View {
// LLM replies arrive as Markdown (bold, lists, headings, tables), // rendered with the chat-bubble-sized Strand theme. User bubbles stay
// verbatim `Text` so typed `*`/`#` never turn into surprise formatting.
// The reply sits on a frosted Charge-tinted surface, a card, not a flat box.
+ // K8: context menu (long-press / right-click) with Copy, Share, and Save actions.
HStack {
Markdown(message.text)
.markdownTheme(.strand)
@@ -487,6 +691,27 @@ struct CoachView: View {
.padding(.vertical, 11)
.frostedCardSurface(tint: StrandPalette.chargeColor, cornerRadius: 16)
.frame(maxWidth: 560, alignment: .leading)
+ // K8: Copy / Share / Save context menu on assistant replies.
+ .contextMenu {
+ Button {
+ #if os(macOS)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(message.text, forType: .string)
+ #else
+ UIPasteboard.general.string = message.text
+ #endif
+ } label: {
+ Label("Copy", systemImage: "doc.on.doc")
+ }
+ ShareLink(item: message.text) {
+ Label("Share", systemImage: "square.and.arrow.up")
+ }
+ Button {
+ saveAdvice(message.text)
+ } label: {
+ Label("Save to Journal", systemImage: "square.and.pencil")
+ }
+ }
Spacer(minLength: 48)
}
.accessibilityElement(children: .combine)
@@ -552,6 +777,59 @@ struct CoachView: View {
}
}
+ /// K7: True when follow-up chips should show instead of the initial contextual chips —
+ /// i.e. the transcript is non-empty, the last message is from the assistant, and a reply
+ /// is not currently in flight.
+ private var showFollowUpChips: Bool {
+ guard let last = coach.messages.last, !coach.sending else { return false }
+ return last.role == .assistant
+ }
+
+ /// K7: Follow-up suggestion chips shown after each assistant reply, so the user can dig
+ /// deeper without typing. Uses the static `AICoachEngine.followUpSuggestions` list.
+ private var followUpChips: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(AICoachEngine.followUpSuggestions, id: \.self) { prompt in
+ Button {
+ send(prompt)
+ } label: {
+ Text(prompt)
+ .font(StrandFont.captionNumber)
+ .foregroundStyle(StrandPalette.textSecondary)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 7)
+ .background(StrandPalette.surfaceInset, in: Capsule(style: .continuous))
+ .overlay(Capsule(style: .continuous).strokeBorder(StrandPalette.hairline, lineWidth: 1))
+ }
+ .buttonStyle(LiquidPressStyle())
+ .disabled(coach.sending)
+ .accessibilityLabel("Follow-up prompt: \(prompt)")
+ }
+ }
+ .padding(.vertical, 1)
+ }
+ }
+
+ /// K12: A subtle token estimate shown below the composer when the draft is non-empty.
+ /// Uses the ~4 chars/token heuristic — an estimate only, not an exact tokenizer count.
+ private func tokenEstimateBar(_ tokens: Int) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: "speedometer")
+ .font(.system(size: 10))
+ .foregroundStyle(StrandPalette.textTertiary)
+ Text("~\(tokens) tokens")
+ .font(StrandFont.captionNumber)
+ .foregroundStyle(StrandPalette.textTertiary)
+ if tokens > 8000 {
+ Text("· may exceed small context windows")
+ .font(StrandFont.captionNumber)
+ .foregroundStyle(StrandPalette.textTertiary)
+ }
+ }
+ .padding(.top, 2)
+ }
+
/// The input bar, a frosted overlay surface holding the field + Send, so the composer reads as a
/// distinct docked surface above the canvas rather than two floating controls.
private var composer: some View {
@@ -570,6 +848,11 @@ struct CoachView: View {
.onSubmit { send(draft) }
.accessibilityLabel("Question")
+ // K4: on-device voice input (iOS only). macOS compiles this section out entirely.
+ #if os(iOS)
+ micButton
+ #endif
+
// Docked icon-only send affordance: a crisp accent-filled square sized to the
// composer row (not the full 48pt control height), so it routes through the same
// token fill/label colours as the button system without overpowering the field.
@@ -599,6 +882,82 @@ struct CoachView: View {
.strokeBorder(StrandPalette.hairline, lineWidth: 1))
}
+ // MARK: - K4: Voice input (iOS only)
+
+ #if os(iOS)
+ /// Mic button: starts/stops on-device speech recognition. Disabled when the locale lacks
+ /// on-device support or permission is denied; tapping when permission is not yet determined
+ /// triggers the system prompt.
+ private var micButton: some View {
+ Button {
+ toggleVoice()
+ } label: {
+ Group {
+ if voiceInput.isRecording {
+ Image(systemName: "stop.circle.fill")
+ .font(.system(size: 18, weight: .medium))
+ .foregroundStyle(StrandPalette.statusCritical)
+ } else {
+ Image(systemName: "mic.fill")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(canUseVoice ? StrandPalette.textSecondary : StrandPalette.textTertiary)
+ }
+ }
+ .frame(width: 36, height: 38)
+ .background(StrandPalette.surfaceInset,
+ in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(StrandPalette.hairline, lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ .disabled(!micButtonEnabled)
+ .help(voiceInput.statusMessage ?? "Ask out loud")
+ .accessibilityLabel(voiceInput.isRecording ? "Stop voice input" : "Voice input")
+ .accessibilityHint(voiceInput.statusMessage ?? "Transcribes your question on-device")
+ .task {
+ // Pre-check on appear so the button reflects the right state without a tap.
+ if voiceInput.authorization == .notDetermined {
+ voiceInput.requestAuthorization { _ in }
+ }
+ }
+ }
+
+ /// Whether the mic button is tappable: not while sending, and only if voice is either
+ /// already usable or permission hasn't been asked yet (first tap triggers the prompt).
+ private var canUseVoice: Bool { voiceInput.canUseVoice }
+ private var micButtonEnabled: Bool {
+ !coach.sending && (canUseVoice || voiceInput.authorization == .notDetermined)
+ }
+
+ private func toggleVoice() {
+ if voiceInput.isRecording {
+ voiceInput.stopTranscribing { finalText in
+ let trimmed = finalText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ // Append to the draft (not replace) so a user can speak into existing text.
+ draft = draft.isEmpty ? trimmed : "\(draft) \(trimmed)"
+ }
+ }
+ } else {
+ // First tap with undetermined permission triggers the system prompt; if granted,
+ // start transcribing immediately on the next tap. If already authorized, start now.
+ if voiceInput.authorization == .notDetermined {
+ voiceInput.requestAuthorization { state in
+ if state == .authorized {
+ voiceInput.startTranscribing { partial in
+ draft = partial
+ }
+ }
+ }
+ } else {
+ voiceInput.startTranscribing { partial in
+ draft = partial
+ }
+ }
+ }
+ }
+ #endif
+
private var privacyFootnote: some View {
Label {
Text(coach.provider == .custom
@@ -641,6 +1000,30 @@ struct CoachView: View {
Task { await coach.send(trimmed) }
}
+ /// K14: Trigger a subtle haptic when the Coach reply arrives. On iOS, a light impact feedback.
+ /// macOS doesn't have an equivalent simple API, so it's a no-op there.
+ private func triggerReplyHaptic() {
+ #if os(iOS)
+ let generator = UIImpactFeedbackGenerator(style: .light)
+ generator.impactOccurred()
+ #endif
+ }
+
+ /// K8: Save a coach reply to the journal as a note, so it appears alongside other journal
+ /// entries in Insights and can be reviewed later. Uses the existing journal API with a
+ /// fixed question ("Coach advice") and the reply text in the notes field.
+ private func saveAdvice(_ text: String) {
+ let day = Repository.localDayKey(Date())
+ Task {
+ await repo.saveJournalAnswer(
+ day: day,
+ question: "Coach advice",
+ answeredYes: true,
+ notes: text
+ )
+ }
+ }
+
private func scrollToEnd(_ proxy: ScrollViewProxy) {
withAnimation(StrandMotion.fade) {
if coach.sending {
diff --git a/Strand/Screens/ConsistencyCard.swift b/Strand/Screens/ConsistencyCard.swift
index ee77b2f7c8..d4f1e576e4 100644
--- a/Strand/Screens/ConsistencyCard.swift
+++ b/Strand/Screens/ConsistencyCard.swift
@@ -31,7 +31,8 @@ struct ConsistencyCard: View {
StatTile(
label: "Consistency",
value: pctValue(cons.latest),
- caption: vsTypical(cons.latest, cons.typical, suffix: "%"),
+ caption: tileCaption(latestDay: cons.latestDay, latest: cons.latest,
+ typical: cons.typical, suffix: "%"),
accent: cons.latest.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary,
sparkline: spark(cons.series),
sparkColor: StrandPalette.metricCyan)
@@ -45,6 +46,15 @@ struct ConsistencyCard: View {
v.map { "\(Int($0.rounded()))%" } ?? "—"
}
+ /// #1946: a carried prior-day value is stamped "Carried · " instead of "vs typical".
+ private func tileCaption(latestDay: String?, latest: Double?, typical: Double?,
+ suffix: String, decimals: Int = 0) -> String {
+ if let carried = SleepModel.carriedMetricCaption(latestDay: latestDay, latest: latest) {
+ return carried
+ }
+ return vsTypical(latest, typical, suffix: suffix, decimals: decimals)
+ }
+
/// "+12% vs typical" — the latest-vs-mean caption the metric tile carries.
private func vsTypical(_ latest: Double?, _ typical: Double?, suffix: String, decimals: Int = 0) -> String {
guard let latest, let typical, typical != 0 else { return String(localized: "vs typical - ") }
diff --git a/Strand/Screens/CoupledView.swift b/Strand/Screens/CoupledView.swift
index 317483417c..a5a0b2fe3b 100644
--- a/Strand/Screens/CoupledView.swift
+++ b/Strand/Screens/CoupledView.swift
@@ -46,9 +46,7 @@ struct CoupledView: View {
/// the cold-start threshold, which keeps the broad overnight-band bonus.
@State private var habitualMidsleepSec: Int? = nil
- /// #614: TODAY's workout count from the SAME deduped/dismissed-filtered list the Workouts screen shows
- /// (`repo.workoutRows`), not `DailyMetric.exerciseCount` (strap-DETECTED only — a Health-Connect import
- /// with auto-detect off read as 0 while the Workouts screen showed it). Loaded in `.task`.
+ /// Today's all-source workout count materialized onto the same physiological cycle as the other cards.
@State private var workoutsToday: Int = 0
/// The day the coupled read describes, today's resolved row (the same `resolveToday` #304/#144 boundary
@@ -159,14 +157,7 @@ struct CoupledView: View {
// bed→wake span below resolves identically (#294). Re-runs on a sync/import refresh.
.task(id: repo.refreshSeq) {
habitualMidsleepSec = await repo.habitualMidsleepSec()
- // #614: count TODAY's workouts by their START's logical day (the SAME 04:00-rollover key
- // DailyMetric.day uses), so the Coupled stat equals the Workouts overview for today.
- let key = todayKey
- // A short window is enough to count today's rows (the cross-source dedup is local to the set),
- // and avoids loading the full history just for one number.
- workoutsToday = await repo.workoutRows(days: 2).filter {
- Repository.logicalDayKey(Date(timeIntervalSince1970: TimeInterval($0.startTs))) == key
- }.count
+ workoutsToday = day?.exerciseCount ?? 0
}
}
@@ -339,7 +330,7 @@ struct CoupledView: View {
caloriesText,
tint: StrandPalette.metricAmber)
heroStat(String(localized: "Workouts"),
- "\(workoutsToday)", // #614: real workout count (all sources), not exerciseCount
+ "\(workoutsToday)",
tint: StrandPalette.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -705,12 +696,9 @@ struct CoupledView: View {
Self.clockFmt.string(from: Date(timeIntervalSince1970: TimeInterval(ts)))
}
- private static let clockFmt: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.setLocalizedDateFormatFromTemplate("jmm")
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var clockFmt: DateFormatter { AppClock.hourMinuteFormatter() }
/// "6h 42m" from a minutes count, for the slept-vs-needed read. Mirror EXACTLY in Kotlin.
static func hoursMinutes(_ minutes: Double) -> String {
diff --git a/Strand/Screens/DashboardCards.swift b/Strand/Screens/DashboardCards.swift
index 34a7430485..eef7805467 100644
--- a/Strand/Screens/DashboardCards.swift
+++ b/Strand/Screens/DashboardCards.swift
@@ -37,6 +37,13 @@ enum DashboardCard: String, CaseIterable, Identifiable {
/// adds it via CUSTOMISE, matching the manual-first / default-OFF posture.
case coupled
+ /// Optional, default-OFF (#1862): a tap-through that opens the Coach launcher SHEET rather than pushing
+ /// a screen — the one card that does. Coach is otherwise buried in More, and entering it means leaving
+ /// Today. Like `coupled` it carries no metric value of its own and is absent from `defaultSelection`, so
+ /// a fresh install never shows it: someone who does not use a provider should not gain a fixed dashboard
+ /// row for one. Opening the sheet makes NO provider request — see `CoachLauncherSheet`.
+ case coach
+
var id: String { rawValue }
/// The card's display label (the UPPERCASE WHOOP metric-row label is derived from this). Localized via
@@ -58,6 +65,7 @@ enum DashboardCard: String, CaseIterable, Identifiable {
case .calories: return String(localized: "Calories")
case .hydration: return String(localized: "Hydration")
case .coupled: return String(localized: "Coupled view")
+ case .coach: return String(localized: "Coach")
}
}
@@ -79,6 +87,9 @@ enum DashboardCard: String, CaseIterable, Identifiable {
case .calories: return String(localized: "Active energy")
case .hydration: return String(localized: "Today's fluid")
case .coupled: return String(localized: "Recovery, strain and sleep in one glance")
+ // Reuses the Coach screen's own subtitle, so the card and the screen describe the feature
+ // identically and no new copy needs translating into ten locales.
+ case .coach: return String(localized: "Ask about your charge, effort, rest and workouts, grounded in your own numbers.")
}
}
@@ -99,6 +110,7 @@ enum DashboardCard: String, CaseIterable, Identifiable {
case .calories: return "flame.fill"
case .hydration: return "waterbottle.fill"
case .coupled: return "circle.hexagongrid.fill"
+ case .coach: return "bubble.left.and.text.bubble.right.fill"
}
}
@@ -119,6 +131,7 @@ enum DashboardCard: String, CaseIterable, Identifiable {
case .calories: return "kcal"
case .hydration: return "" // value bakes in " / L" itself
case .coupled: return "" // a tap-through row, no value, so no unit
+ case .coach: return "" // likewise: a launcher row, no metric of its own
}
}
diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift
index d00f23aab2..5c5d57346d 100644
--- a/Strand/Screens/DevicesView.swift
+++ b/Strand/Screens/DevicesView.swift
@@ -91,6 +91,13 @@ private struct DevicesContent: View {
/// has produced any clock signal - a routed frame, a clock correlation, or a data-range reply - so a
/// generic HR strap or an idle card never shows a fabricated "waiting" state. One computation for
/// both the line and the warning (the log scan is the cost worth paying once, not twice).
+ ///
+ /// #1818: the battery term reads `batterySamples.last`, NOT `batteryPct`. `batteryPct` deliberately
+ /// outlives the link (`clearBiometrics` leaves it) so Today/the widget can show a last-known charge,
+ /// and a 21 h old reading is indistinguishable from a fresh one there (#530). Withdrawing the "charge
+ /// it" advice on a stale 100% would suppress it in exactly the case it is right - a strap that ran
+ /// flat, which is what reset the RTC, reconnecting before its first battery event. `batterySamples`
+ /// is cleared on disconnect, so `.last` is a reading from THIS link or nothing.
private var strapClockState: (line: String, warning: String?)? {
guard live.connected else { return nil }
let deviceClock = ConnectionReadout.clockCorrelatedDevice(logLines: live.log)
@@ -100,7 +107,8 @@ private struct DevicesContent: View {
let frame = ConnectionReadout.lastFrameLabel(lastFrameUnix: live.lastFrameAtUnix,
nowUnix: Int(Date().timeIntervalSince1970))
let warning = ConnectionReadout.rtcWarning(deviceClockUnix: deviceClock,
- strapNewestUnix: live.strapRange?.newestUnix)
+ strapNewestUnix: live.strapRange?.newestUnix,
+ batteryPct: live.batterySamples.last?.soc)
return (String(localized: "Clock latched: \(latched) · last frame \(frame)"), warning)
}
diff --git a/Strand/Screens/FusedRecordView.swift b/Strand/Screens/FusedRecordView.swift
index 1266ddcb07..487ea354ed 100644
--- a/Strand/Screens/FusedRecordView.swift
+++ b/Strand/Screens/FusedRecordView.swift
@@ -202,6 +202,15 @@ private struct FusedMetricRowView: View {
let showProvenance: Bool
let onCompare: () -> Void
+ // Each screen resolves °C/°F for itself (TodayView, FullDayChartView, MetricExplorerView do the
+ // same); the fused row used to print a hardcoded "°C" and was the last reader here ignoring it.
+ @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
+ @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = ""
+ private var temperatureUnit: TemperatureUnit {
+ UnitPrefs.resolveTemperature(system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: temperatureRaw)
+ }
+
private var point: FusedMetricPoint { row.point }
private var accent: Color {
@@ -217,7 +226,7 @@ private struct FusedMetricRowView: View {
.font(StrandFont.headline)
.foregroundStyle(StrandPalette.textPrimary)
Spacer(minLength: 8)
- Text(FusionFormat.value(point.value, metricKey: point.metric))
+ Text(FusionFormat.value(point.value, metricKey: point.metric, temperature: temperatureUnit))
.font(StrandFont.number(20))
.foregroundStyle(accent)
.lineLimit(1)
@@ -268,7 +277,7 @@ private struct FusedMetricRowView: View {
case .agree:
if let other = point.contributors.dropFirst().first {
- Text("\(other.source.displayName) agrees: \(FusionFormat.value(other.value, metricKey: point.metric))")
+ Text("\(other.source.displayName) agrees: \(FusionFormat.value(other.value, metricKey: point.metric, temperature: temperatureUnit))")
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
}
@@ -277,7 +286,7 @@ private struct FusedMetricRowView: View {
if let other = point.contributors.dropFirst().first {
HStack(spacing: 6) {
StatePill("Differs slightly", tone: .neutral, showsDot: false)
- Text("\(other.source.displayName): \(FusionFormat.value(other.value, metricKey: point.metric))")
+ Text("\(other.source.displayName): \(FusionFormat.value(other.value, metricKey: point.metric, temperature: temperatureUnit))")
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textSecondary)
Spacer(minLength: 0)
@@ -306,7 +315,7 @@ private struct FusedMetricRowView: View {
/// "Apple Health says 6h 40m. Tap to compare" style line for a conflict.
private var conflictSummary: String {
guard let other = point.contributors.dropFirst().first else { return String(localized: "Tap to compare") }
- return String(localized: "\(other.source.displayName) says \(FusionFormat.value(other.value, metricKey: point.metric)). Tap to compare")
+ return String(localized: "\(other.source.displayName) says \(FusionFormat.value(other.value, metricKey: point.metric, temperature: temperatureUnit)). Tap to compare")
}
}
@@ -369,6 +378,15 @@ private struct ContributorRow: View {
let metricKey: String
let isWinner: Bool
+ // Each screen resolves °C/°F for itself (TodayView, FullDayChartView, MetricExplorerView do the
+ // same); the fused row used to print a hardcoded "°C" and was the last reader here ignoring it.
+ @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
+ @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = ""
+ private var temperatureUnit: TemperatureUnit {
+ UnitPrefs.resolveTemperature(system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: temperatureRaw)
+ }
+
var body: some View {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
@@ -384,7 +402,7 @@ private struct ContributorRow: View {
.foregroundStyle(StrandPalette.textTertiary)
}
Spacer(minLength: 8)
- Text(FusionFormat.value(contrib.value, metricKey: metricKey))
+ Text(FusionFormat.value(contrib.value, metricKey: metricKey, temperature: temperatureUnit))
.font(StrandFont.number(18))
.foregroundStyle(isWinner ? StrandPalette.textPrimary : StrandPalette.textSecondary)
.lineLimit(1)
@@ -394,8 +412,8 @@ private struct ContributorRow: View {
.accessibilityElement(children: .combine)
// Whole-string key per variant (never a concatenated localized tail on an a11y label).
.accessibilityLabel(isWinner
- ? "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey)), in use"
- : "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey))")
+ ? "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey, temperature: temperatureUnit)), in use"
+ : "\(contrib.source.displayName), \(FusionFormat.value(contrib.value, metricKey: metricKey, temperature: temperatureUnit))")
}
}
@@ -403,9 +421,10 @@ private struct ContributorRow: View {
/// Formats a fused metric's `Double` value for display by its resolver key. Pure + local to this
/// screen: the engine deals in numbers, the UI owns units. Sleep/duration keys read as "7h 12m";
-/// temp as "34.1°C"; HR/HRV/steps as plain integers with the right unit.
+/// temp as "34.1 °C" / "93.4 °F" per the reader's preference; HR/HRV/steps as plain
+/// integers with the right unit.
enum FusionFormat {
- static func value(_ v: Double, metricKey: String) -> String {
+ static func value(_ v: Double, metricKey: String, temperature: TemperatureUnit) -> String {
switch MetricArbitrationPolicy.kind(forKey: metricKey) {
case .restingHR, .heartRate:
return "\(Int(v.rounded())) bpm"
@@ -414,7 +433,12 @@ enum FusionFormat {
case .spo2:
return "\(Int(v.rounded()))%"
case .skinTemp:
- return String(format: "%.1f°C", v)
+ // #111/#622: this column is BIMODAL — CSV/Health imports carry an ABSOLUTE wrist °C (~30-35),
+ // the live BLE pipeline a signed DEVIATION from baseline. Fusion is the one screen that can
+ // show both at once, side by side, so the conversion cannot be assumed: +32 belongs only to
+ // the absolute reading, and applying it to a −4.2 deviation is what once printed "24.4 °F".
+ // SkinTempDisplay picks the conversion AND the chip ("°F" vs "Δ°F") from the value itself.
+ return SkinTempDisplay.format(v, fahrenheit: temperature == .fahrenheit)
case .steps:
return integerGrouped(v)
case .sleep:
diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift
index e9b30c1bbb..b168717dae 100644
--- a/Strand/Screens/HealthView.swift
+++ b/Strand/Screens/HealthView.swift
@@ -135,7 +135,10 @@ private struct SyncStatusSection: View {
@EnvironmentObject var model: AppModel
/// The strap link is usable for a manual offload kick (matches BLEManager.syncNow's own gate).
- private var canSync: Bool { live.connected && live.bonded && !live.backfilling }
+ ///
+ /// `historyReady`, not `bonded` alone: the live-HR path sets `bonded` for a 5/MG that has never
+ /// completed a handshake, so this enabled itself and beginBackfill then declined it silently.
+ private var canSync: Bool { live.connected && live.bonded && live.historyReady && !live.backfilling }
var body: some View {
VStack(alignment: .leading, spacing: NoopMetrics.gap) {
@@ -187,8 +190,10 @@ private struct SyncStatusSection: View {
.foregroundStyle(StrandPalette.textSecondary)
}
} else {
- StatePill(live.bonded ? "Ready to sync" : "Pairing…",
- tone: .accent, showsDot: true, pulsing: !live.bonded)
+ // Same condition as the button below it. Keyed on `bonded` this said "Ready to sync" directly
+ // above a DISABLED Sync now, on exactly the strap that cannot sync.
+ StatePill(live.historyReady ? "Ready to sync" : "Pairing…",
+ tone: .accent, showsDot: true, pulsing: !live.historyReady)
}
}
@@ -199,7 +204,11 @@ private struct SyncStatusSection: View {
if !live.connected {
return String(localized: "Connect your strap to sync its stored history. Until then, only imported data shows here.")
}
- if !live.bonded {
+ // historyReady, not `bonded`. This branch already said the right thing and simply never fired on
+ // the strap that needed it: `bonded` is set by the live-HR path, so a 5/MG that never completed a
+ // handshake fell through to the "syncs right away" line, under a Sync-now button that had just
+ // been disabled. Same condition as the button and the pill, so all three agree.
+ if !live.historyReady {
return String(localized: "Finishing the pairing handshake. Sync now becomes available once the strap is paired.")
}
return String(localized: "Syncs your strap's stored history right away, instead of waiting for the next automatic sync.")
@@ -1227,6 +1236,7 @@ private struct VitalsSection: View {
// Temperature display preference (D#103). Skin temp is stored in °C (absolute or a ±deviation); the
// toggle re-labels it to °F. Display-only — banding still runs on the stored °C value.
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
+ @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846
@AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = ""
private var temperatureUnit: TemperatureUnit {
let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric
@@ -1244,7 +1254,8 @@ private struct VitalsSection: View {
sourceRows: repo.vitalMetricRows,
temperatureUnit: temperatureUnit,
spo2CandidateByDay: spo2CandidateByDay,
- hrvOverCountByDay: hrvOverCountByDay
+ hrvOverCountByDay: hrvOverCountByDay,
+ skinTempPreferred: SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute // #1846
)
VStack(alignment: .leading, spacing: NoopMetrics.gap) {
SectionHeader("Vital Signs", overline: "Latest", trailing: BodyVitalSigns.latestDayLabel(readings))
diff --git a/Strand/Screens/HoursVsNeededCard.swift b/Strand/Screens/HoursVsNeededCard.swift
index 63feec0fca..2dfb638b90 100644
--- a/Strand/Screens/HoursVsNeededCard.swift
+++ b/Strand/Screens/HoursVsNeededCard.swift
@@ -30,7 +30,8 @@ struct HoursVsNeededCard: View {
StatTile(
label: "Hours vs Needed",
value: pctValue(need.latest),
- caption: vsTypical(need.latest, need.typical, suffix: "%"),
+ caption: tileCaption(latestDay: need.latestDay, latest: need.latest,
+ typical: need.typical, suffix: "%"),
accent: need.latest.map { StrandPalette.recoveryColor(min(100, $0)) } ?? StrandPalette.textPrimary,
sparkline: spark(need.series),
sparkColor: StrandPalette.restColor)
@@ -44,6 +45,15 @@ struct HoursVsNeededCard: View {
v.map { "\(Int($0.rounded()))%" } ?? "—"
}
+ /// #1946: a carried prior-day value is stamped "Carried · " instead of "vs typical".
+ private func tileCaption(latestDay: String?, latest: Double?, typical: Double?,
+ suffix: String, decimals: Int = 0) -> String {
+ if let carried = SleepModel.carriedMetricCaption(latestDay: latestDay, latest: latest) {
+ return carried
+ }
+ return vsTypical(latest, typical, suffix: suffix, decimals: decimals)
+ }
+
/// "+12% vs typical" — the latest-vs-mean caption the metric tile carries.
private func vsTypical(_ latest: Double?, _ typical: Double?, suffix: String, decimals: Int = 0) -> String {
guard let latest, let typical, typical != 0 else { return String(localized: "vs typical - ") }
diff --git a/Strand/Screens/HydrationView.swift b/Strand/Screens/HydrationView.swift
index d5f865ec10..dc67d012df 100644
--- a/Strand/Screens/HydrationView.swift
+++ b/Strand/Screens/HydrationView.swift
@@ -274,9 +274,9 @@ struct HydrationView: View {
}
}
- private static let entryTimeFmt: DateFormatter = {
- let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var entryTimeFmt: DateFormatter { AppClock.hourMinuteFormatter() }
// MARK: - 7-day mini history (flat bars, today on the right)
diff --git a/Strand/Screens/InsightsHubView.swift b/Strand/Screens/InsightsHubView.swift
index 9bfca37e78..a5fe185a22 100644
--- a/Strand/Screens/InsightsHubView.swift
+++ b/Strand/Screens/InsightsHubView.swift
@@ -546,6 +546,8 @@ final class InsightsHubViewModel: ObservableObject {
// MARK: Loaded inputs (kept so the outcome segmented control can re-rank cheaply)
private var behaviours: [String: Set] = [:]
+ /// Per behaviour, the days it was logged NO — the only legitimate control group.
+ private var controls: [String: Set] = [:]
private var outcomeByKey: [String: [String: Double]] = [:]
private var currentOutcome: Outcome = .recovery
@@ -559,9 +561,13 @@ final class InsightsHubViewModel: ObservableObject {
func load(repo: Repository) async {
// Journal → behaviour → days (only "yes" answers count as the behaviour occurring).
let entries = await repo.journalEntries()
+ // Yes days and NO days, kept apart. A day with no journal row for the question lands in
+ // neither, so an unanswered day is never counted as a No (BehaviorInsights.effect).
var byBehaviour: [String: Set] = [:]
- for e in entries where e.answeredYes {
- byBehaviour[e.question, default: []].insert(e.day)
+ var controlsByBehaviour: [String: Set] = [:]
+ for e in entries {
+ if e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) }
+ else { controlsByBehaviour[e.question, default: []].insert(e.day) }
}
// Outcome series: imported metricSeries ∪ the DailyMetric column fallback so an
@@ -611,6 +617,7 @@ final class InsightsHubViewModel: ObservableObject {
}
self.behaviours = byBehaviour
+ self.controls = controlsByBehaviour
self.outcomeByKey = byKey
self.doseCards = cards
self.loaded = true
@@ -622,6 +629,7 @@ final class InsightsHubViewModel: ObservableObject {
currentOutcome = outcome
let outcomeDays = outcomeByKey[outcome.key] ?? [:]
ranked = EffectRanker.rank(behaviors: behaviours,
+ controls: controls,
outcomeByDay: outcomeDays,
outcome: outcome.outcomeName)
}
diff --git a/Strand/Screens/InsightsView.swift b/Strand/Screens/InsightsView.swift
index af2c34b2d6..7d455bdcce 100644
--- a/Strand/Screens/InsightsView.swift
+++ b/Strand/Screens/InsightsView.swift
@@ -40,6 +40,9 @@ private struct InsightsLoadKey: Equatable {
/// the seq AND the dayKey still match (see `Repository.insightsLoadedSeq` / `insightsLoadedDayKey`).
struct InsightsLoadCache {
let behaviours: [String: Set]
+ /// Per behaviour, the days it was logged NO. Cached alongside `behaviours` because a restore that
+ /// dropped it would leave the ranker with no controls and silently produce no insights at all.
+ let controls: [String: Set]
let importedQuestions: [String]
let dayAnswers: [String: Bool]
/// The journal day offset the `dayAnswers` were read for (0 = today, 1 = yesterday, -1 = tomorrow). The
@@ -152,6 +155,8 @@ struct InsightsView: View {
/// behaviour question → set of days where it was answered yes.
@State private var behaviours: [String: Set] = [:]
+ /// Per behaviour, the days it was logged NO — the only legitimate control group.
+ @State private var controls: [String: Set] = [:]
/// outcome key → [day: value].
@State private var outcomeByKey: [String: [String: Double]] = [:]
/// outcome key → ordered (day, value) series for correlations.
@@ -351,10 +356,14 @@ struct InsightsView: View {
// log writes answeredYes=true too (#322), so a numeric item lands in the with/without split
// here unchanged, on top of the numeric series read below.
let entries = await repo.journalEntries()
+ // Yes days and NO days, kept apart. A day with no journal row for the question lands in
+ // neither, so an unanswered day is never counted as a No (BehaviorInsights.effect).
var byBehaviour: [String: Set] = [:]
+ var controlsByBehaviour: [String: Set] = [:]
var numericByBehaviour: [String: [String: Double]] = [:]
- for e in entries where e.answeredYes {
- byBehaviour[e.question, default: []].insert(e.day)
+ for e in entries {
+ if e.answeredYes { byBehaviour[e.question, default: []].insert(e.day) }
+ else { controlsByBehaviour[e.question, default: []].insert(e.day) }
}
// #322: per-question numeric series (question → [day: value]) for numeric journal items. A
// numeric series is the same [day: value] shape a metric outcome is, so the effect ranker can
@@ -415,6 +424,7 @@ struct InsightsView: View {
await MainActor.run {
self.behaviours = byBehaviour
+ self.controls = controlsByBehaviour
self.importedQuestions = importedQs
self.dayAnswers = nativeAnswers
self.dayNumeric = nativeNumeric
@@ -432,6 +442,7 @@ struct InsightsView: View {
// freshest read, a subsequent re-mount never restores stale data behind a journal toggle.
self.repo.insightsCache = InsightsLoadCache(
behaviours: byBehaviour,
+ controls: controlsByBehaviour,
importedQuestions: importedQs,
dayAnswers: nativeAnswers,
journalDayOffset: self.journalDayOffset,
@@ -450,6 +461,7 @@ struct InsightsView: View {
@MainActor
private func restoreFromCache(_ c: InsightsLoadCache) {
behaviours = c.behaviours
+ controls = c.controls
importedQuestions = c.importedQuestions
dayAnswers = c.dayAnswers
// Numeric journal rows are native-only (imported WHOOP rows never carry a numericValue), so the
@@ -516,6 +528,7 @@ struct InsightsView: View {
let outcomeDays = outcomeByKey[outcome.key] ?? [:]
ranked = BehaviorInsights.rank(
behaviors: behaviours,
+ controls: controls,
outcomeByDay: outcomeDays,
outcome: outcome.outcomeName
)
diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift
index aad7ca8e1f..d9dc0364df 100644
--- a/Strand/Screens/LiveView.swift
+++ b/Strand/Screens/LiveView.swift
@@ -361,28 +361,44 @@ struct LiveView: View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Circle().fill(StrandPalette.metricRose).frame(width: 8, height: 8)
- Text("RECORDING WORKOUT").font(StrandFont.overline)
- .tracking(StrandFont.overlineTracking).foregroundStyle(StrandPalette.metricRose)
+ // "RECORDING" is a factual claim, and while paused nothing IS being recorded — the
+ // sample capture drops every reading. So the label swaps rather than gaining a tag
+ // beside it, which would leave the card asserting both at once. Reuses the "Paused"
+ // string #1533 already localized. (The Today card says "IN PROGRESS", which stays
+ // true while paused, so it keeps its label and takes the tag instead.)
+ Text(w.isPaused ? "Paused" : "RECORDING WORKOUT").font(StrandFont.overline)
+ .tracking(StrandFont.overlineTracking)
+ .foregroundStyle(w.isPaused ? StrandPalette.textSecondary : StrandPalette.metricRose)
Spacer()
// Re-render once a second so the elapsed clock ticks without a manual Timer.
- TimelineView(.periodic(from: .now, by: 1)) { _ in
- Text(Self.elapsed(since: w.start)).font(StrandFont.number(17)).monospacedDigit()
+ TimelineView(.periodic(from: .now, by: 1)) { context in
+ Text(ActiveWorkoutClock.clock(Int(w.elapsed(at: context.date))))
+ .font(StrandFont.number(17)).monospacedDigit()
.foregroundStyle(StrandPalette.textPrimary)
}
}
// Live HR / avg / peak / effort — the leaf owns LiveState + the active workout so the
// 1 Hz stat refresh re-renders only these tiles, plus a liquid effort tube under them.
ActiveWorkoutLive(workout: w, effortScale: effortScale)
+ // The card used to offer End and nothing else, so the only IRREVERSIBLE control was the
+ // one reachable without opening the live view, while Pause — the reversible one — was
+ // not. Pause/Resume is one toggle (a paused session has exactly one sensible action), and
+ // End moves to its own row so a destructive tap is not adjacent to a routine one.
HStack(spacing: NoopMetrics.rowSpacing) {
+ NoopButton(w.isPaused ? "Resume" : "Pause",
+ systemImage: w.isPaused ? "play.fill" : "pause.fill",
+ kind: .secondary, fullWidth: true) {
+ model.toggleWorkoutPause()
+ }
// Re-open the full live workout screen (#238) after it's been dismissed.
NoopButton("Open live view", systemImage: "rectangle.expand.vertical",
kind: .secondary, fullWidth: true) {
showLiveWorkout = true
}
- NoopButton("End workout", systemImage: "stop.circle.fill",
- kind: .destructive, fullWidth: true) {
- confirmingEndWorkout = true
- }
+ }
+ NoopButton("End workout", systemImage: "stop.circle.fill",
+ kind: .destructive, fullWidth: true) {
+ confirmingEndWorkout = true
}
}
}
@@ -401,11 +417,6 @@ struct LiveView: View {
.padding(.horizontal, 4)
}
- private static func elapsed(since start: Date) -> String {
- let s = max(0, Int(Date().timeIntervalSince(start)))
- return String(format: "%d:%02d", s / 60, s % 60)
- }
-
private func reconnectGuideBanner(_ guide: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift
index 1fa83dd943..f1a8b06799 100644
--- a/Strand/Screens/LiveWorkoutView.swift
+++ b/Strand/Screens/LiveWorkoutView.swift
@@ -318,27 +318,59 @@ struct LiveWorkoutView: View {
// MARK: - Bottom floating controls
- /// Diameter shared by the exit and workout-type Liquid Glass circles so the centered timer stays
- /// optically balanced against equal side chrome.
+ /// Diameter shared by every Liquid Glass circle in the bottom capsule, so the row reads as one
+ /// set of controls rather than a mix of sizes.
+ ///
+ /// It used to justify itself as keeping "the centered timer optically balanced against equal side
+ /// chrome". The side chrome has not been equal since #1533 put two circles on the left and one on
+ /// the right, and the timer is no longer centred against them — see `bottomControlRow`.
private static let bottomControlDiameter: CGFloat = 56
/// Tight inset so the glass circles nest into the capsule ends (stopwatch-bar proportions).
private static let bottomBarInset: CGFloat = 4
- /// One shared dark floating capsule: Liquid Glass exit · elapsed · Liquid Glass workout-type.
- /// The timer is centered in the bar via ZStack; the glass circles sit in a separate HStack so
- /// uneven label widths cannot pull the time off-center. The capsule itself is solid elevated
- /// chrome — not Liquid Glass.
+ /// One shared dark floating capsule: discard · pause · elapsed · end.
+ ///
+ /// Laid out in ONE HStack, so the timer and the controls cannot overlap. #1068 built this as a
+ /// ZStack with the timer centred independently — deliberately, "so uneven label widths cannot pull
+ /// the time off-center" — and that held while the bar carried one circle per side. #1533 added the
+ /// discard and pause controls to the left group, and a centred 40pt timer then began where two
+ /// 56pt circles plus their spacing end: `0:02` merely touched the pause button, and anything wider
+ /// went under it. A field report of "two timers" was this one half-occluded, read as a duplicate of
+ /// the big TIME readout above. `.allowsHitTesting(false)` on the timer was already a tell that it
+ /// sat beneath something tappable.
+ ///
+ /// The trade is deliberate: the timer now sits centred in the space the buttons leave rather than
+ /// in the bar, so it reads slightly right of true centre because the left chrome is heavier. That
+ /// is the cost of the layout being unable to collide at all. The buttons keep the positions they
+ /// have shipped with — moving pause to the right would centre the timer better and would also move
+ /// a control under the thumb of everyone already using this screen, which is a worse trade than an
+ /// off-centre clock.
+ ///
+ /// It can still run out of ROOM, and the arithmetic is tighter than it looks: three 56pt circles
+ /// and their gaps leave the timer roughly 161pt on a 393pt screen, against about 144pt for a
+ /// `1:30:00` at 40pt monospaced. On a 375pt device, or at a larger Dynamic Type, that does not fit,
+ /// so the timer scales rather than overflowing the capsule. The alternative considered — padding
+ /// the timer clear of the widest group to keep true centring — left it barely 105pt and would have
+ /// truncated the same clock outright.
private var bottomControlRow: some View {
- ZStack {
+ HStack(spacing: NoopMetrics.space2) {
+ deleteWorkoutGlassButton
+ pauseWorkoutGlassButton
+ Spacer(minLength: NoopMetrics.space2)
bottomElapsedTimer
.allowsHitTesting(false)
-
- HStack(spacing: NoopMetrics.space2) {
- deleteWorkoutGlassButton
- pauseWorkoutGlassButton
- Spacer(minLength: 0)
- endWorkoutGlassButton
- }
+ // Scaling down is the honest failure when the room runs out: truncating a clock to
+ // "1:30:0" would be worse than a smaller one. Same idiom the rest of this file uses.
+ .lineLimit(1)
+ .minimumScaleFactor(0.6)
+ // NO layoutPriority here, deliberately. Raising the timer's priority sizes it BEFORE
+ // the three circles, and those are fixed 56pt frames — inflexible, so when the space
+ // runs out they do not shrink, they clip. That inverts which element gives way: a
+ // half-drawn pause button is worse than a smaller clock, and a clipped control is the
+ // failure this whole change exists to remove. At equal priority the inflexible frames
+ // are satisfied first and the Text scales into what is left, which is the order wanted.
+ Spacer(minLength: NoopMetrics.space2)
+ endWorkoutGlassButton
}
.padding(Self.bottomBarInset)
.background {
@@ -423,9 +455,12 @@ struct LiveWorkoutView: View {
// MARK: - Helpers
+ /// Delegates to the shared clock. This carried its own `%d:%02d` with NO hour roll-over, so a
+ /// 90-minute session read "90:00" here while Android's live workout screen read "1:30:00" — and,
+ /// after the card fix, while the iOS card that opens THIS screen read "1:30:00" too. The math was
+ /// already pause-aware (`workout.elapsed()`); only the formatting was the odd one out.
private static func elapsed(seconds: TimeInterval) -> String {
- let s = max(0, Int(seconds))
- return String(format: "%d:%02d", s / 60, s % 60)
+ ActiveWorkoutClock.clock(Int(seconds))
}
private static func zoneName(_ zone: Int) -> String {
@@ -463,8 +498,8 @@ private extension View {
/// power meter) feeding RSC/CSC/CPS ALONGSIDE heart rate. Only the fields the sensor actually sent
/// render — each metric is dropped when its value is absent, and the WHOLE block (panel + entrance stagger)
/// is hidden when nothing is present (`live.hasSensorMetrics`), so a plain HR-only workout looks exactly
-/// as before. Honest units: speed km/h, cadence per-minute (steps for running / rpm for cycling), power
-/// watts. Tinted with the Effort world so it reads as part of the hero, not a competing accent. Nothing
+/// as before. Speed follows the exercise-distance preference; cadence stays per-minute and power in watts.
+/// Tinted with the Effort world so it reads as part of the hero, not a competing accent. Nothing
/// here touches HR / zone / effort.
///
/// This is a standalone leaf that owns its OWN `@EnvironmentObject live` (the parent `LiveWorkoutView`
@@ -475,10 +510,18 @@ private extension View {
/// so the rendered output matches the previous inline code.
private struct SensorRowIfPresent: View {
@EnvironmentObject private var live: LiveState
+ @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(
+ system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: distanceSystemRaw)
+ }
var body: some View {
if live.hasSensorMetrics {
- let speed = LiveState.formatSpeedKmh(live.sensorSpeedKmh)
+ let speed = UnitFormatter.speedFromKilometersPerHour(
+ live.sensorSpeedKmh, system: distanceUnitSystem)
let cadence = LiveState.formatCadence(live.sensorCadence)
let power = LiveState.formatPowerWatts(live.sensorPowerWatts)
NoopCard(padding: NoopMetrics.cardInnerPadding, tint: StrandPalette.effortColor) {
@@ -487,7 +530,7 @@ private struct SensorRowIfPresent: View {
.font(StrandFont.overline).tracking(StrandFont.overlineTracking)
.foregroundStyle(StrandPalette.textSecondary)
HStack(spacing: NoopMetrics.gap) {
- if let speed { stat(String(localized: "SPEED"), "\(speed) km/h", tint: StrandPalette.effortColor) }
+ if let speed { stat(String(localized: "SPEED"), speed, tint: StrandPalette.effortColor) }
if let cadence { stat(String(localized: "CADENCE"), "\(cadence)/min", tint: StrandPalette.effortColor) }
if let power { stat(String(localized: "POWER"), "\(power) W", tint: StrandPalette.effortColor) }
}
@@ -525,7 +568,12 @@ private struct SensorRowIfPresent: View {
private struct DistancePaceRowIfPresent: View {
@ObservedObject var recorder: GpsWorkoutRecorder
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
- private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(
+ system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: distanceSystemRaw)
+ }
var body: some View {
// `isRecording` is essential, not just `pointCount > 0`: the recorder is a single long-lived
@@ -539,10 +587,10 @@ private struct DistancePaceRowIfPresent: View {
// "Distance"/"Pace" are already localized (reused from the detail view); uppercased for
// the caps stat grid, exactly as the detail route stats do.
stat(String(localized: "Distance").uppercased(),
- UnitFormatter.distanceFromMeters(recorder.distanceM, system: unitSystem))
+ UnitFormatter.distanceFromMeters(recorder.distanceM, system: distanceUnitSystem))
statDivider
stat(String(localized: "Pace").uppercased(),
- UnitFormatter.paceFromSecPerKm(recorder.paceSecPerKm, system: unitSystem))
+ UnitFormatter.paceFromSecPerKm(recorder.paceSecPerKm, system: distanceUnitSystem))
}
}
}
diff --git a/Strand/Screens/ManualWorkoutSheet.swift b/Strand/Screens/ManualWorkoutSheet.swift
index a316ca9f0f..7011bedd45 100644
--- a/Strand/Screens/ManualWorkoutSheet.swift
+++ b/Strand/Screens/ManualWorkoutSheet.swift
@@ -37,12 +37,15 @@ struct ManualWorkoutSheet: View {
/// Distance as ENTERED, in the user's unit (km or mi) — converted to stored metres on save (#1195).
@State private var distanceText: String
- /// The length unit system, so the Distance field reads/writes in the user's unit. Read live for the
- /// field label + parse; the init pre-fill reads the same key straight from UserDefaults (@AppStorage
- /// isn't available before `self` is formed).
+ /// Exercise-distance choice. Unset follows the original combined setting for existing installs.
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
- private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
- private var distanceUnit: String { unitSystem == .imperial ? "mi" : "km" }
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(
+ system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: distanceSystemRaw)
+ }
+ private var distanceUnit: String { UnitFormatter.distanceUnit(distanceUnitSystem) }
/// Focus for the numeric (Avg HR / Calories / Distance) fields so the keyboard Done button can resign
/// them — the decimal pad has no return key. iOS-only effect; the enum keeps both platforms compiling.
@@ -74,7 +77,11 @@ struct ManualWorkoutSheet: View {
// Pre-fill the distance in the user's unit so an untouched edit round-trips the stored metres
// (buildManualRow then re-stores exactly what's shown). @AppStorage isn't usable pre-init, so read
// the same key directly.
- let sys = UnitSystem(rawValue: UserDefaults.standard.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric
+ let bodySystem = UnitSystem(
+ rawValue: UserDefaults.standard.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric
+ let sys = UnitPrefs.resolveDistance(
+ system: bodySystem,
+ override: UserDefaults.standard.string(forKey: UnitPrefs.distanceSystemKey) ?? "")
_distanceText = State(initialValue: e?.distanceM.map { Self.distanceEntryString($0, system: sys) } ?? "")
}
@@ -383,7 +390,7 @@ struct ManualWorkoutSheet: View {
private var distanceMeters: Double? {
let t = distanceText.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty, let v = Double(t), v >= 0 else { return nil }
- let km = unitSystem == .imperial ? v / UnitFormatter.milesPerKilometer : v
+ let km = distanceUnitSystem == .imperial ? v / UnitFormatter.milesPerKilometer : v
return km * 1000.0
}
@@ -425,7 +432,9 @@ struct ManualWorkoutSheet: View {
}
if !distanceText.trimmingCharacters(in: .whitespaces).isEmpty,
distanceMeters == nil || (distanceMeters ?? -1) < 0 || (distanceMeters ?? 0) > 1_000_000 {
- return String(localized: "Distance must be 0–1,000 km.")
+ return distanceUnitSystem == .imperial
+ ? String(localized: "Distance must be 0–621 mi.")
+ : String(localized: "Distance must be 0–1,000 km.")
}
return String(localized: "Check the values and try again.")
}
diff --git a/Strand/Screens/MetricExplorerView.swift b/Strand/Screens/MetricExplorerView.swift
index fd71b0e2a7..887828a9db 100644
--- a/Strand/Screens/MetricExplorerView.swift
+++ b/Strand/Screens/MetricExplorerView.swift
@@ -182,6 +182,18 @@ func vo2MaxAttributionSource(_ estimator: Vo2MaxEstimator?) -> String {
vo2MaxAttributionPrefix + (estimator?.rawValue ?? "unknown")
}
+/// Will the chart show a visible break in this VO₂max trend?
+///
+/// Derived from `vo2MaxTrendSegmentIds` rather than recomputed, so the caption and the segmentation can
+/// never disagree. A GAP IN DAYS under one estimator is still a single segment and draws no break, so it
+/// correctly gets no caption: a break means the readings were not produced alike, not that the data
+/// paused. Named for the BREAK: an untagged legacy reading resolves to "...estimator:unknown", so an
+/// unknown -> Nes transition splits the line while the method itself may never have changed.
+/// Kotlin twin `vo2MaxTrendHasBreak`.
+func vo2MaxTrendHasBreak(days: [String], sourceByDay: [String: String]) -> Bool {
+ Set(vo2MaxTrendSegmentIds(days: days, sourceByDay: sourceByDay)).count > 1
+}
+
/// Sequential segment ids for the VO₂max trend. The counter matters when a user changes Nes → Uth → Nes:
/// using the method name alone would reconnect the two non-adjacent Nes runs across the Uth interval.
func vo2MaxTrendSegmentIds(days: [String], sourceByDay: [String: String]) -> [String] {
@@ -253,6 +265,32 @@ private let readingShortDateFormatter: DateFormatter = {
return f
}()
+// MARK: - Skin-temp explorer notes (#1847 / #1848)
+
+/// Whether the skin-temp explorer must explain that it fell back to deviations despite the
+/// user's Settings choice asking for temperatures. Twin of Android's `shouldExplainSkinTempFallback`.
+///
+/// True only when the user asked for absolute, the screen is NOT leading with absolutes, and NO
+/// night in the window carries one — so the fallback is total, not partial. A window with one
+/// stored temperature and twenty deltas still leads with temperatures (the #1850 window-wide rule),
+/// so this note stays silent there; it fires only when the setting genuinely cannot be honoured.
+func shouldExplainSkinTempFallback(prefer: SkinTempDisplay.Kind, leadsAbsolute: Bool,
+ anyAbsoluteInWindow: Bool) -> Bool {
+ prefer == .absolute && !leadsAbsolute && !anyAbsoluteInWindow
+}
+
+/// Whether the skin-temp explorer must explain that deviation-only nights were dropped from the
+/// series when leading with absolutes. Twin of Android's `shouldExplainShortenedSkinTempSeries`.
+///
+/// True ONLY when leading with the absolute — the deviation-led branch also drops rows (calibrating
+/// nights that have only an absolute, and the #622 bimodal partition), but those are the OPPOSITE
+/// kind, so this note's sentence would be precisely backwards there. True only when rows were
+/// actually dropped, so a complete series stays silent.
+func shouldExplainShortenedSkinTempSeries(leadsAbsolute: Bool, shownReadings: Int,
+ rowsWithEitherNumber: Int) -> Bool {
+ leadsAbsolute && shownReadings < rowsWithEitherNumber
+}
+
// MARK: - Root: categorized list
/// The "Explore" picker — categories as sections, metrics as rows, each pushing a
@@ -526,11 +564,18 @@ struct MetricDetailView: View {
// Effort display scale (#268) — routes the Effort metric's numbers + unit; display-only, the plotted
// series stays 0–100. Every other metric is scale-agnostic (see MetricDescriptor.format).
@AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue
+ /// #1846/#1848: which skin-temp number the explorer leads with — absent/`""` = a temperature
+ /// (the default), or `SkinTempDisplay.Kind.deviation.rawValue` to lead with the ±baseline move.
+ /// Same key as Today/Health/Settings; display-only, nothing stored ever changes.
+ @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = ""
private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
private var temperatureUnit: TemperatureUnit {
UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw)
}
private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) }
+ private var skinTempPreferred: SkinTempDisplay.Kind {
+ SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute
+ }
private func fmt(_ v: Double) -> String {
metric.format(v, system: unitSystem, temperature: temperatureUnit, effortScale: effortScale)
}
@@ -557,6 +602,13 @@ struct MetricDetailView: View {
/// reads it, so it can lag the rest of the screen by a second without anyone noticing.
@State private var correlationsLoaded = false
+ /// #1848: the skin-temp explorer's explanatory note (nil when none is needed). Set in `load()`
+ /// alongside the series, from the same `repo.days` scan that decides which kind to lead with.
+ /// Two notes, mutually exclusive: (1) the user asked for temperatures but the window has none,
+ /// so deviations are shown instead; (2) leading with absolutes dropped deviation-only nights
+ /// from the series. Both are real on Apple — the same two cases the Android twin carries.
+ @State private var skinTempNote: String? = nil
+
/// Cached correlation scan, keyed by its inputs (selected range + the metric id),
/// so the full cross-catalog Pearson sweep runs ONLY when those change — not on
/// every body re-eval (hover / 1 Hz HR ticks / animation). Recomputed from
@@ -564,7 +616,7 @@ struct MetricDetailView: View {
@State private var correlationCache: [CorrRow] = []
/// The (metricID, range) the cache was built for; nil means "not yet computed".
@State private var correlationKey: String? = nil
- private var loadTaskID: String { "\(metric.id)|\(repo.refreshSeq)" }
+ private var loadTaskID: String { "\(metric.id)|\(repo.refreshSeq)|\(skinTempDisplayRaw)" }
// MARK: Derived
@@ -734,6 +786,23 @@ struct MetricDetailView: View {
// with the range pill. Then the frosted chart / stat tiles / correlations.
heroHeader(effectiveRange: effRange, windowed: win, windowFellBack: fellBack)
heroChart(effectiveRange: effRange, windowed: win, windowFellBack: fellBack)
+ // #1848: the skin-temp explorer's explanatory note (nil for every other metric and
+ // for a skin-temp screen that needs no explanation). Sits between the chart and the
+ // stats so it reads as context for the series just plotted, not as a generic banner.
+ if let note = skinTempNote {
+ NoopCard {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "info.circle")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundStyle(StrandPalette.textTertiary)
+ .accessibilityHidden(true)
+ Text(note)
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
statRow(effectiveRange: effRange, windowed: win)
readingsTable(windowed: win)
correlationCard
@@ -840,6 +909,81 @@ struct MetricDetailView: View {
series = byDay.sorted { $0.key < $1.key }.map { (day: $0.key, value: $0.value) }
}
}
+ // #1848: skin-temp explorer — give it a skin-temp-specific branch mirroring the Android
+ // `buildVitalDetail` "skin" case, rather than the shared `dailyColumn` → `skinTempDevC` path.
+ //
+ // The shared mapper returns `skinTempDevC` only, so the explorer never leads with the measured
+ // absolute (`skinTempC`) and ignores the Settings choice (#1846). Repointing `dailyColumn`'s
+ // `skin_temp` case would change which series Trends plots and what the band is computed against
+ // (its callers include the trend series and the metric-catalog availability check), so the fix
+ // is a branch HERE, not a change to the shared mapper.
+ //
+ // The preference applies across the WINDOW (#1850), not just the newest row: a wearer with
+ // twenty stored temperatures and one recent night without saw twenty-three deltas — the setting
+ // says Temperature and the app HAS temperatures. `leadReading`'s rule lifted to the window: the
+ // chosen kind wins whenever any night carries it, the other is still the fallback, so a choice
+ // can never empty the screen.
+ //
+ // An absolute may live in EITHER column (#622: a WHOOP CSV import writes absolute °C into
+ // `skinTempDevC`), so both count here and in the series below.
+ skinTempNote = nil
+ if metric.key == "skin_temp" {
+ let days = repo.days
+ let anyAbsolute = days.contains { row in
+ row.skinTempC != nil
+ || row.skinTempDevC.map(VitalBands.isAbsoluteSkinTemp) == true
+ }
+ let anyDeviation = days.contains { row in
+ row.skinTempDevC.map { !VitalBands.isAbsoluteSkinTemp($0) } == true
+ }
+ if anyAbsolute || anyDeviation {
+ let leadsAbsolute: Bool
+ switch skinTempPreferred {
+ case .absolute: leadsAbsolute = anyAbsolute
+ case .deviation: leadsAbsolute = !anyDeviation && anyAbsolute
+ }
+ if leadsAbsolute {
+ // An absolute-led series takes EVERY absolute reading, whichever column holds it.
+ // `skinTempC` is the on-device computed absolute; `skinTempDevC` may hold a CSV-imported
+ // absolute (#622 bimodal). Mixing is only unsound ACROSS scales; these are the same scale.
+ series = days.compactMap { row in
+ let v = row.skinTempC
+ ?? row.skinTempDevC.flatMap { VitalBands.isAbsoluteSkinTemp($0) ? $0 : nil }
+ return v.map { (day: row.day, value: $0) }
+ }.sorted { $0.day < $1.day }
+ } else {
+ // Genuine deviations only — an imported absolute sitting in `skinTempDevC` belongs to
+ // the other scale and is excluded, exactly as before.
+ series = days.compactMap { row in
+ row.skinTempDevC.flatMap { !VitalBands.isAbsoluteSkinTemp($0) ? $0 : nil }
+ .map { (day: row.day, value: $0) }
+ }.sorted { $0.day < $1.day }
+ }
+ // Rebuild sourceByDay for the new series. `DailyMetric` has no per-row deviceId on Apple
+ // (the merged cache doesn't carry one), so every reading attributes to the metric's own
+ // source — the same source the `exploreSeries` daily-column layer would have used.
+ sourceByDay = Dictionary(series.map { ($0.day, metric.source) },
+ uniquingKeysWith: { first, _ in first })
+ // The two #1847 notes — both cases exist on Apple too:
+ // (1) Settings asked for a temperature and none of these nights has one → say so, because
+ // silently falling back is why the setting reads as broken. Nights scored before
+ // `skinTempC` shipped kept only the deviation; a scoring pass refills them.
+ // (2) Leading with the absolute drops deviation-only nights from the series — which can
+ // now include the most recent one. Say why rather than letting history look like it
+ // vanished. (Deviation-led drops are the OPPOSITE kind, so the note is gated to
+ // absolute-led only — see `shouldExplainShortenedSkinTempSeries`.)
+ let shownReadings = series.count
+ let rowsWithEither = days.count { $0.skinTempC != nil || $0.skinTempDevC != nil }
+ if shouldExplainSkinTempFallback(prefer: skinTempPreferred, leadsAbsolute: leadsAbsolute,
+ anyAbsoluteInWindow: anyAbsolute) {
+ skinTempNote = String(localized: "No measured temperature for these nights — showing the difference from your baseline instead. A re-score refills temperatures for nights that have one.")
+ } else if shouldExplainShortenedSkinTempSeries(leadsAbsolute: leadsAbsolute,
+ shownReadings: shownReadings,
+ rowsWithEitherNumber: rowsWithEither) {
+ skinTempNote = String(localized: "Only nights with a measured temperature are shown — the others only have a baseline difference.")
+ }
+ }
+ }
// #943 selection seam: a locked default (.month with under a week of history) no longer
// OVERWRITES @State range - it renders through `coercedSelection` instead (non-destructive,
// recomputed every body eval), so a shrinking history re-coerces and a growing one un-coerces
@@ -1074,11 +1218,31 @@ struct MetricDetailView: View {
valueFormat: { fmt($0) }
)
} footer: {
- ChartFooter([
- ("Window", effectiveRange.label),
- ("Points", "\(windowed.count)"),
- ("Latest", heroValue),
- ])
+ // #1662: the VO₂max line is SPLIT on purpose wherever the estimator changes, so two
+ // non-adjacent Nes runs are never joined across an incompatible Uth stretch. Nothing said so,
+ // and a silent gap in a trend is indistinguishable from a rendering fault — it was reported
+ // as "something weird with a broken line". Shown only when a break actually exists, so it
+ // explains the chart in front of the reader rather than a behaviour they cannot see.
+ //
+ // In the FOOTER, not beside the chart: `ChartCard` applies `chart().frame(height:)` to that
+ // whole closure, so a caption in there would be squeezed into the chart's fixed height and
+ // steal space from the line it is explaining. The footer is the only full-height slot under
+ // the chart. Android places it directly under the plot because its card has no such frame -
+ // feature parity, not pixel parity.
+ VStack(alignment: .leading, spacing: 8) {
+ if metric.key == "vo2max_est",
+ vo2MaxTrendHasBreak(days: windowed.map(\.day), sourceByDay: sourceByDay) {
+ Text("The line breaks where the estimation method changed or was not recorded.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ ChartFooter([
+ ("Window", effectiveRange.label),
+ ("Points", "\(windowed.count)"),
+ ("Latest", heroValue),
+ ])
+ }
}
}
@@ -1179,7 +1343,14 @@ struct MetricDetailView: View {
let readings = windowed.map {
VitalReading(day: $0.day, value: $0.value, source: sourceByDay[$0.day] ?? metric.source)
}
- let rows = vitalReadingRows(readings: readings, unit: metric.unit,
+ // The unit is passed EMPTY on purpose (#1942). `vitalReadingRows` appends its `unit` to whatever
+ // the formatter returns, and every `MetricDescriptor.format` overload already ends in the unit —
+ // the CONVERTED one, at that. Passing `metric.unit` here rendered "33 % %", and worse than a
+ // repeat for the three convertible families, whose stored label contradicts the displayed one:
+ // "182.0 lb kg", "Δ0.5 °F °C", "12.5 /21 /100". The Android twin appends the same way and is
+ // correct because every one of its call sites passes a UNIT-LESS closure; iOS has no unit-less
+ // formatter that also converts, so the unit comes from `fmt` and the parameter stays empty.
+ let rows = vitalReadingRows(readings: readings, unit: "",
strapDeviceId: repo.deviceId, format: fmt)
if !rows.isEmpty {
NoopCard {
diff --git a/Strand/Screens/NightDetailCard.swift b/Strand/Screens/NightDetailCard.swift
index 57dafb5eca..dcd0a029af 100644
--- a/Strand/Screens/NightDetailCard.swift
+++ b/Strand/Screens/NightDetailCard.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import StrandAnalytics
import StrandDesign
import WhoopStore
@@ -40,8 +41,8 @@ struct NightDetailCard: View {
StatTile(
label: "Sleep Debt",
value: debt.latest.map { durationText($0) } ?? "—",
- caption: debtCaption(debt.latest),
- accent: debtColor(debt.latest),
+ caption: nightDetailDebtCaption(debt.latest),
+ accent: nightDetailDebtColor(debt.latest),
sparkline: spark(debt.series),
sparkColor: StrandPalette.metricRose)
.frame(maxWidth: .infinity)
@@ -52,7 +53,8 @@ struct NightDetailCard: View {
StatTile(
label: "Rest",
value: pctValue(perf.latest),
- caption: vsTypical(perf.latest, perf.typical, suffix: "%"),
+ caption: tileCaption(latestDay: perf.latestDay, latest: perf.latest,
+ typical: perf.typical, suffix: "%"),
accent: perf.latest.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary,
sparkline: spark(perf.series),
sparkColor: StrandPalette.restColor)
@@ -60,7 +62,8 @@ struct NightDetailCard: View {
StatTile(
label: "Efficiency",
value: pctValue(eff.latest),
- caption: vsTypical(eff.latest, eff.typical, suffix: "%"),
+ caption: tileCaption(latestDay: eff.latestDay, latest: eff.latest,
+ typical: eff.typical, suffix: "%"),
accent: StrandPalette.statusPositive,
sparkline: spark(eff.series),
sparkColor: StrandPalette.statusPositive)
@@ -68,7 +71,8 @@ struct NightDetailCard: View {
StatTile(
label: "Consistency",
value: pctValue(cons.latest),
- caption: vsTypical(cons.latest, cons.typical, suffix: "%"),
+ caption: tileCaption(latestDay: cons.latestDay, latest: cons.latest,
+ typical: cons.typical, suffix: "%"),
accent: cons.latest.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary,
sparkline: spark(cons.series),
sparkColor: StrandPalette.metricCyan)
@@ -76,7 +80,8 @@ struct NightDetailCard: View {
StatTile(
label: "Hours vs Needed",
value: pctValue(need.latest),
- caption: vsTypical(need.latest, need.typical, suffix: "%"),
+ caption: tileCaption(latestDay: need.latestDay, latest: need.latest,
+ typical: need.typical, suffix: "%"),
accent: need.latest.map { StrandPalette.recoveryColor(min(100, $0)) } ?? StrandPalette.textPrimary,
sparkline: spark(need.series),
sparkColor: StrandPalette.restColor)
@@ -84,7 +89,8 @@ struct NightDetailCard: View {
StatTile(
label: "Restorative",
value: pctValue(rest.latest),
- caption: vsTypical(rest.latest, rest.typical, suffix: "%"),
+ caption: tileCaption(latestDay: rest.latestDay, latest: rest.latest,
+ typical: rest.typical, suffix: "%"),
accent: StrandPalette.sleepREM,
sparkline: spark(rest.series),
sparkColor: StrandPalette.sleepREM)
@@ -92,7 +98,8 @@ struct NightDetailCard: View {
StatTile(
label: "Respiratory",
value: rrValue(resp.latest),
- caption: vsTypical(resp.latest, resp.typical, suffix: " rpm", decimals: 1),
+ caption: tileCaption(latestDay: resp.latestDay, latest: resp.latest,
+ typical: resp.typical, suffix: " rpm", decimals: 1),
accent: StrandPalette.metricPurple,
sparkline: spark(resp.series),
sparkColor: StrandPalette.metricPurple)
@@ -103,8 +110,8 @@ struct NightDetailCard: View {
StatTile(
label: "Sleep Debt",
value: debt.latest.map { durationText($0) } ?? "—",
- caption: debtCaption(debt.latest),
- accent: debtColor(debt.latest),
+ caption: nightDetailDebtCaption(debt.latest),
+ accent: nightDetailDebtColor(debt.latest),
sparkline: spark(debt.series),
sparkColor: StrandPalette.metricRose)
#endif
@@ -122,6 +129,17 @@ struct NightDetailCard: View {
v.map { String(format: "%.1f", $0) } ?? "—"
}
+ /// #1946: a carried prior-day value is stamped "Carried · " instead of "vs typical", so it
+ /// is never passed off as tonight's read. Falls through to `vsTypical` when the value is today's
+ /// own (or there is no value).
+ private func tileCaption(latestDay: String?, latest: Double?, typical: Double?,
+ suffix: String, decimals: Int = 0) -> String {
+ if let carried = SleepModel.carriedMetricCaption(latestDay: latestDay, latest: latest) {
+ return carried
+ }
+ return vsTypical(latest, typical, suffix: suffix, decimals: decimals)
+ }
+
/// "+12% vs typical" / "−0.4 rpm vs typical" — the latest-vs-mean caption every tile carries.
private func vsTypical(_ latest: Double?, _ typical: Double?, suffix: String, decimals: Int = 0) -> String {
guard let latest, let typical, typical != 0 else { return String(localized: "vs typical - ") }
@@ -132,20 +150,6 @@ struct NightDetailCard: View {
return String(localized: "\(sign)\(num)\(suffix) vs typical")
}
- private func debtCaption(_ debt: Double?) -> String {
- guard let debt else { return String(localized: "vs need") }
- return debt < 15 ? String(localized: "On target") : String(localized: "Below need")
- }
-
- private func debtColor(_ debt: Double?) -> Color {
- guard let debt else { return StrandPalette.textPrimary }
- switch debt {
- case ..<15: return StrandPalette.statusPositive
- case ..<60: return StrandPalette.statusWarning
- default: return StrandPalette.statusCritical
- }
- }
-
/// A sparkline needs at least two points; otherwise return nil so the tile stays clean.
private func spark(_ series: [Double]) -> [Double]? {
let tail = Array(series.suffix(30))
@@ -160,3 +164,17 @@ struct NightDetailCard: View {
return String(localized: "\(m / 60)h \(m % 60)m")
}
}
+
+func nightDetailDebtCaption(_ debt: Double?) -> String {
+ guard let debt else { return String(localized: "vs need") }
+ return debt < SleepDebt.onTargetBandMin ? String(localized: "On target") : String(localized: "Below need")
+}
+
+func nightDetailDebtColor(_ debt: Double?) -> Color {
+ guard let debt else { return StrandPalette.textPrimary }
+ switch debt {
+ case .. 7 * 86_400)
+ }
+ }
+ }
+
+ private var sessionsSection: some View {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("Recorded sessions").font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary)
+ if store.sessions.isEmpty {
+ Text("No sessions recorded yet.").font(StrandFont.subhead)
+ .foregroundStyle(StrandPalette.textSecondary)
+ } else {
+ NoopButton("Delete all sessions", systemImage: "trash", kind: .destructive,
+ fullWidth: true) { confirmDeleteAll = true }
+ .disabled(store.active != nil)
+ ForEach(store.sessions) { session in sessionCard(session) }
+ }
+ }
+ }
+
+ private func sessionCard(_ session: RawDataSessionStore.Session) -> some View {
+ let coverageText = imuCoverage[session.id, default: "no complete seconds"]
+ return StrandCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary)
+ if !session.active, let endMs = session.endedAtMs {
+ DatePicker("From", selection: Binding(
+ get: { Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000) },
+ set: { store.setRange(sessionId: session.id, from: $0,
+ to: Date(timeIntervalSince1970: Double(endMs) / 1_000)) }
+ ))
+ DatePicker("To", selection: Binding(
+ get: { Date(timeIntervalSince1970: Double(endMs) / 1_000) },
+ set: { store.setRange(sessionId: session.id,
+ from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) }
+ ))
+ }
+ Text(session.active ? String(localized: "Export status: recording")
+ : String(localized: "IMU: \(coverageText)"))
+ .font(StrandFont.caption)
+ .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive)
+ if let exportedAt = session.lastExportedAtMs {
+ Text(String(localized: "Last exported \(Self.time(exportedAt)) · export remains available"))
+ .font(StrandFont.caption)
+ .foregroundStyle(StrandPalette.textSecondary)
+ }
+ TextField("Session comment", text: Binding(
+ get: { store.sessions.first(where: { $0.id == session.id })?.comment ?? session.comment },
+ set: { store.setComment($0, sessionId: session.id) }
+ ), axis: .vertical)
+ .textFieldStyle(.roundedBorder).lineLimit(2...4)
+ NoopButton("Add marker", systemImage: "mappin.and.ellipse", kind: .secondary,
+ fullWidth: true) { editMarker(nil, in: session) }
+ let markers = session.events.filter { $0.kind == "marker" }.sorted { $0.atMs < $1.atMs }
+ ForEach(markers) { marker in
+ Button { editMarker(marker, in: session) } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: NoopMetrics.space1) {
+ (Text(Self.markerLabel(marker.markerType))
+ + Text(verbatim: " · \(Self.time(marker.atMs))"))
+ .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary)
+ if let text = marker.text, !text.isEmpty {
+ Text(text).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ }
+ }
+ Spacer()
+ Image(systemName: "chevron.right").foregroundStyle(StrandPalette.textTertiary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ NoopButton(exportingId == session.id ? "Building export…" : "Export session",
+ systemImage: "square.and.arrow.up", kind: .secondary, fullWidth: true) {
+ Task { await export(session) }
+ }
+ .disabled(session.active || exportingId != nil)
+ NoopButton("Delete session", systemImage: "trash", kind: .destructive,
+ fullWidth: true) { deleteCandidate = session }
+ .disabled(session.active || exportingId != nil)
+ }
+ }
+ }
+
+ private func markerSheet(_ initial: MarkerDraft) -> some View {
+ NavigationStack {
+ if let binding = Binding($markerDraft) {
+ let session = store.sessions.first(where: { $0.id == binding.wrappedValue.sessionId })
+ Form {
+ Section {
+ TimelineView(.periodic(from: .now, by: 1)) { timeline in
+ let current = Self.markerCurrentTime(session: session, now: timeline.date)
+ Text("Marker: \(binding.wrappedValue.at.formatted(date: .omitted, time: .standard))")
+ Text("Current time: \(current.formatted(date: .omitted, time: .standard))")
+ .foregroundStyle(.secondary)
+ HStack {
+ Button { binding.wrappedValue.at.addTimeInterval(-10) } label: { Text(verbatim: "−10 s") }
+ Spacer()
+ Button("0") {
+ binding.wrappedValue.at = Self.markerCurrentTime(session: session, now: Date())
+ }
+ Spacer()
+ Button { binding.wrappedValue.at.addTimeInterval(10) } label: { Text(verbatim: "+10 s") }
+ }
+ }
+ }
+ Section("Marker type") {
+ Picker("Marker type", selection: binding.type) {
+ ForEach(RawDataSessionStore.markerTypes, id: \.self) { type in
+ Text(Self.markerLabel(type)).tag(type)
+ }
+ }.pickerStyle(.segmented)
+ }
+ Section("Marker note") {
+ TextField("Marker note", text: binding.text, axis: .vertical).lineLimit(2...4)
+ }
+ if let markerId = binding.wrappedValue.markerId {
+ Section {
+ Button("Delete marker", role: .destructive) {
+ store.deleteMarker(sessionId: binding.wrappedValue.sessionId, markerId: markerId)
+ markerDraft = nil
+ }
+ }
+ }
+ }
+ .navigationTitle(initial.markerId == nil ? "Add marker" : "Edit marker")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) { Button("Cancel") { markerDraft = nil } }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") { saveMarker(binding.wrappedValue) }
+ }
+ }
+ }
+ }
+ }
+
+ private func editMarker(_ marker: RawDataSessionStore.Event?, in session: RawDataSessionStore.Session) {
+ let currentMs = session.endedAtMs ?? Int64(Date().timeIntervalSince1970 * 1_000)
+ markerDraft = MarkerDraft(sessionId: session.id, markerId: marker?.markerId,
+ at: Date(timeIntervalSince1970: Double(marker?.atMs ?? currentMs) / 1_000),
+ type: marker?.markerType ?? "moment", text: marker?.text ?? "")
+ }
+
+ private func saveMarker(_ draft: MarkerDraft) {
+ if let markerId = draft.markerId {
+ store.updateMarker(sessionId: draft.sessionId, markerId: markerId,
+ at: draft.at, type: draft.type, text: draft.text)
+ } else {
+ store.addMarker(sessionId: draft.sessionId, at: draft.at, type: draft.type, text: draft.text)
+ }
+ markerDraft = nil
+ }
+
+ private func start() {
+ guard let session = store.start(deviceId: model.ble.deviceId) else { return }
+ guard model.ble.startGroundTruthRawCapture(sessionId: session.id) else {
+ store.stop(); store.removeMetadata(session.id); return
+ }
+ }
+
+ private func stop() async {
+ await model.ble.stopGroundTruthRawCapture()
+ store.stop()
+ await refreshImuCoverage()
+ }
+
+ private func export(_ session: RawDataSessionStore.Session) async {
+ guard let end = session.endedAtMs else { return }
+ exportingId = session.id
+ let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end)
+ let from = bounds?.from ?? 1, to = bounds?.to ?? 0
+ let segments = bounds.map {
+ ImuSessionFileStore.shared.exportSegments(session.id, from: $0.from, to: $0.to)
+ } ?? []
+ let history = bounds == nil ? Data("stream,unix_s,v1,v2,v3,v4\n".utf8)
+ : await model.ble.groundTruthHistoryCSV(from: from, to: to)
+ let sensorAvailable = !segments.isEmpty || history.split(separator: 0x0A).count > 1
+ var entries = store.exportEntries(for: session, sensorAvailable: sensorAvailable)
+ entries.append(.init(name: "history-sensors.csv", data: history))
+ // A live capture can only produce its first complete one-second frame after startup.
+ // Historical windows must still cover the exact requested start.
+ let firstImuTs = segments.map(\.startTs).min().flatMap { $0 <= from + 1 ? $0 : nil }
+ let coverageFrom = session.capturedStartedAtMs == nil ? from : max(from, firstImuTs ?? from)
+ let imuComplete = Self.covers(segments, from: coverageFrom, to: to)
+ let coverage: [String: Any] = [
+ "requested_start_ts": from, "requested_end_ts": to,
+ "required_start_ts": coverageFrom,
+ "startup_seconds": max(0, coverageFrom - from),
+ "complete": imuComplete,
+ "segments": segments.map { ["file": $0.name, "start_ts": $0.startTs, "end_ts": $0.endTs,
+ "sample_count": $0.sampleCount] }
+ ]
+ if let data = try? JSONSerialization.data(withJSONObject: coverage, options: [.prettyPrinted, .sortedKeys]) {
+ entries.append(.init(name: "imu-coverage.json", data: data))
+ }
+ for segment in segments { entries.append(.init(name: "imu/\(segment.name)", data: segment.data)) }
+ let result = await FileExport.exportBundle(entries: entries,
+ suggestedName: "noop-5mg-raw-\(session.id).zip")
+ if result == nil { exportError = "The export file could not be created or shared." }
+ else {
+ store.markExported(session.id)
+ }
+ exportingId = nil
+ }
+
+ private func delete(_ session: RawDataSessionStore.Session) async {
+ guard session.endedAtMs != nil else { return }
+ guard store.removeMetadata(session.id) else {
+ deleteError = "The captured data could not be deleted. The session was kept so you can retry."
+ return
+ }
+ imuCoverage[session.id] = nil
+ }
+
+ private func deleteAll() async {
+ for session in store.sessions where !session.active { await delete(session) }
+ }
+
+ private func refreshImuCoverage() async {
+ for session in store.sessions {
+ guard let end = session.endedAtMs,
+ let bounds = Self.fullSecondBounds(fromMs: session.startedAtMs, toMs: end) else { continue }
+ let stats = ImuSessionFileStore.shared.stats(session.id, from: bounds.from, to: bounds.to)
+ let first = stats.firstTs.flatMap { $0 <= Int64(bounds.from + 1) ? Int($0) : nil }
+ let from = session.capturedStartedAtMs == nil ? bounds.from : max(bounds.from, first ?? bounds.from)
+ let expected = max(0, bounds.to - from + 1)
+ let bytes = ByteCountFormatter.string(fromByteCount: stats.bytes, countStyle: .file)
+ let readiness = expected > 0 && stats.coveredSeconds == expected ? "ready" : "incomplete"
+ imuCoverage[session.id] = "\(stats.coveredSeconds)/\(expected) s · \(bytes) · \(readiness)"
+ }
+ }
+
+ private static func time(_ ms: Int64) -> String {
+ Date(timeIntervalSince1970: Double(ms) / 1_000)
+ .formatted(Date.FormatStyle(date: .omitted, time: .shortened)
+ .locale(AppClock.formattingLocale)) // #1821
+ }
+
+ static func fullSecondBounds(fromMs: Int64, toMs: Int64) -> (from: Int, to: Int)? {
+ let from = Int((fromMs + 999) / 1_000), to = Int(toMs / 1_000) - 1
+ return from <= to ? (from, to) : nil
+ }
+
+ static func markerCurrentTime(session: RawDataSessionStore.Session?, now: Date) -> Date {
+ session?.endedAtMs.map { Date(timeIntervalSince1970: Double($0) / 1_000) } ?? now
+ }
+
+ private static func range(_ session: RawDataSessionStore.Session) -> String {
+ let start = Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000)
+ let date = start.formatted(date: .numeric, time: .omitted)
+ let end = session.endedAtMs.map(time) ?? "…"
+ return "\(date) · \(time(session.startedAtMs))–\(end)"
+ }
+
+ private static func markerLabel(_ type: String?) -> LocalizedStringKey {
+ switch type {
+ case "start": "Start"
+ case "end": "End"
+ case "issue": "Issue"
+ default: "Moment"
+ }
+ }
+
+ private static func covers(_ chunks: [ImuSessionFileStore.ExportSegment], from: Int, to: Int) -> Bool {
+ var cursor = from
+ for chunk in chunks.sorted(by: { $0.startTs < $1.startTs }) {
+ let start = chunk.startTs, end = chunk.endTs
+ if chunk.sampleCount < (end - start + 1) * ImuSessionFileStore.sampleRate { continue }
+ if start > cursor { return false }
+ if end >= cursor { cursor = end + 1 }
+ if cursor > to { return true }
+ }
+ return cursor > to
+ }
+}
diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift
index 7c76d02b5a..a17a8642b9 100644
--- a/Strand/Screens/SettingsView.swift
+++ b/Strand/Screens/SettingsView.swift
@@ -35,6 +35,10 @@ struct SettingsView: View {
@State private var backupAlertTitle = ""
@State private var backupAlertMessage = ""
@State private var showBackupAlert = false
+ /// #1807: a restore refused ONLY for size is recoverable, so it gets its own two-button alert rather
+ /// than the shared single-OK one every other backup outcome uses.
+ @State private var showOversizeRestoreConfirm = false
+ @State private var oversizeRestoreMessage = ""
/// Opt-in WHOOP 5/MG protocol experiments (off by default). See [PuffinExperiment].
@AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false
@@ -166,11 +170,19 @@ struct SettingsView: View {
/// WHOOP 4.0) regardless of this switch. See [PuffinExperiment.motionAwareWakeKey].
@AppStorage(PuffinExperiment.motionAwareWakeKey) private var motionAwareWakeEnabled = false
- // Imperial/Metric display preference (D#103). Stored data is always SI; this only changes how
- // distances/weights/heights/temperatures are SHOWN — and lets the profile fields below take
- // imperial entry. Temperature has a separate override so °C/°F can be picked independently.
+ // Display preferences. `units.system` remains the body-measurement choice for compatibility;
+ // exercise distance/pace can override it independently. Stored data is always SI.
+ /// #1821: Clock format. Defaults to `.system`, so upgrading changes nobody's displayed times.
+ /// #1841: shared with Android by name and meaning; each platform keeps its own store. Default FALSE
+ /// on Apple (Android defaults true) because the system behaviour may not fire on our
+ /// `NavigationStack(path:)` tabs — see RootTabView.
+ @AppStorage("noop.bottomBarAutoHide") private var bottomBarAutoHide = false
+ @AppStorage(ClockFormatPreference.defaultsKey)
+ private var clockFormatRaw = ClockFormatPreference.system.rawValue
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
@AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = ""
+ @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846
// Effort display scale (#268). Display-only — Effort stays stored 0–100, this only chooses whether
// it's shown on NOOP's 0–100 axis or WHOOP's 0–21 Day Strain axis.
@AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue
@@ -178,6 +190,7 @@ struct SettingsView: View {
@AppStorage(UnitPrefs.hrvWindowKey) private var hrvWindowRaw = HrvWindow.whole.rawValue
// Live-HR Live Activity (Lock Screen + Dynamic Island), iOS only (#336). Default on.
@AppStorage(UnitPrefs.liveActivityKey) private var liveActivityEnabled = true
+ @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue
// Alternate app icon (iOS only) — false = Titanium (primary AppIcon), true = Blue Titanium
// ("AppIcon-Navy"). Display-only preference; the live switch goes through setAlternateIconName.
@AppStorage("appIcon.alt") private var useNavyIcon = false
@@ -244,6 +257,12 @@ struct SettingsView: View {
}
private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(system: unitSystem, override: distanceSystemRaw)
+ }
+ private var distanceSystemBinding: Binding {
+ Binding(get: { distanceUnitSystem.rawValue }, set: { distanceSystemRaw = $0 })
+ }
private var temperatureUnit: TemperatureUnit {
UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw)
}
@@ -294,6 +313,9 @@ struct SettingsView: View {
/// User-initiated GitHub release check behind the About "Check for updates" button.
@StateObject private var updateChecker = UpdateChecker()
+ /// #1659. Default comes from `UpdateAvailability.defaultEnabled` so the toggle and the launch check
+ /// cannot disagree about what "unset" means.
+ @AppStorage(UpdateWatch.Keys.enabled) private var autoCheckUpdates = UpdateAvailability.defaultEnabled
@Environment(\.openURL) private var openURL
/// Whether the "Advanced" disclosure (Recovery, Test Centre, experimental probes, Backup &
@@ -345,6 +367,15 @@ struct SettingsView: View {
} message: {
Text(backupAlertMessage)
}
+ // Title and buttons reuse catalogue strings that already carry all nine locales, rather than
+ // minting new copy that would ship English everywhere until someone translated it. The message
+ // below is where the specifics live. (#1807)
+ .alert("Backup problem", isPresented: $showOversizeRestoreConfirm) {
+ Button("Restore") { runImport(allowOversize: true) }
+ Button("Cancel", role: .cancel) { }
+ } message: {
+ Text(oversizeRestoreMessage)
+ }
.confirmationDialog("Recalibrate your Charge baseline?",
isPresented: $showRecalibrateConfirm, titleVisibility: .visible) {
Button("Recalibrate") { recalibrateHrvBaseline() }
@@ -514,6 +545,27 @@ struct SettingsView: View {
}
}
rowDivider
+ FormRow(label: "Day cycle") {
+ Picker("Day starts", selection: Binding(
+ get: { DayCycleMode.persisted(dayCycleModeRaw) },
+ set: { mode in
+ dayCycleModeRaw = mode.rawValue
+ Task { await model.intelligence.analyzeRecent(); await model.repo.refresh() }
+ }
+ )) {
+ Text("Main sleep").tag(DayCycleMode.sleepOnset)
+ Text("00:00").tag(DayCycleMode.midnight)
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ }
+ Text(dayCycleModeRaw == DayCycleMode.midnight.rawValue
+ ? "Uses a conventional local calendar day from 00:00 to 00:00."
+ : "Default. Steps and in-progress Effort restart at the beginning of detected main sleep. Naps do not start a new day; missing sleep falls back to local midnight.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ rowDivider
// Step calibration (#139/#132): daily steps = @57 counter ticks ÷ this divisor.
// 1.0 = raw pass-through until the true 5/MG tick rate is known. The divisor goes
// up to 30 because a 5/MG motion counter can overcount by ~24×; the stepper uses a
@@ -938,31 +990,41 @@ struct SettingsView: View {
// MARK: - Units
- /// Imperial/Metric display toggle + a separate temperature override. Display-only — nothing stored
- /// changes, NOOP keeps everything in SI and converts at the point of display.
+ /// Independent body and exercise-distance unit choices plus temperature and Effort overrides.
+ /// Display-only — nothing stored changes; NOOP keeps everything in SI.
private var unitsCard: some View {
SettingsSection(
icon: "ruler",
title: "Units",
- blurb: "Choose how distances, weights, heights, temperatures and Effort are shown. Your data is always stored the same way. This only changes the display."
+ blurb: "Choose body measurements and exercise distance separately. Your data is always stored the same way; these settings only change its display."
) {
VStack(spacing: 0) {
- FormRow(label: "Measurement system") {
- Picker("Measurement system", selection: $unitSystemRaw) {
+ FormRow(label: "Body measurements") {
+ Picker("Body measurements", selection: $unitSystemRaw) {
Text("Metric").tag(UnitSystem.metric.rawValue)
Text("Imperial").tag(UnitSystem.imperial.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
.tint(StrandPalette.accent)
- .accessibilityLabel("Measurement system")
+ .accessibilityLabel("Body measurement units")
+ }
+ rowDivider
+ FormRow(label: "Exercise distance & pace") {
+ Picker("Exercise distance & pace", selection: distanceSystemBinding) {
+ Text("Kilometres").tag(UnitSystem.metric.rawValue)
+ Text("Miles").tag(UnitSystem.imperial.rawValue)
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .tint(StrandPalette.accent)
+ .accessibilityLabel("Exercise distance and pace units")
}
rowDivider
FormRow(label: "Temperature") {
- // Three-way: "Match" follows the system above; °C / °F pin it explicitly. Stored as
- // an empty string ("match") or the TemperatureUnit raw value.
+ // Three-way: "Follow body" follows body measurements; °C / °F pin it explicitly.
Picker("Temperature", selection: $temperatureRaw) {
- Text("Match").tag("")
+ Text("Follow body").tag("")
Text("°C").tag(TemperatureUnit.celsius.rawValue)
Text("°F").tag(TemperatureUnit.fahrenheit.rawValue)
}
@@ -972,6 +1034,20 @@ struct SettingsView: View {
.accessibilityLabel("Temperature unit")
}
rowDivider
+ FormRow(label: "Skin temperature") {
+ // #1846: lead with a temperature ("33.5 °C") or with the move from your own baseline
+ // ("-0.1 Δ°C"). Only a PREFERENCE — a night that measured just one of the two still
+ // shows that one, so the choice can never blank a card.
+ Picker("Skin temperature", selection: $skinTempDisplayRaw) {
+ Text("Temperature").tag("")
+ Text("vs baseline").tag(SkinTempDisplay.Kind.deviation.rawValue)
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .tint(StrandPalette.accent)
+ .accessibilityLabel("Skin temperature display")
+ }
+ rowDivider
// Effort scale (#268) — show NOOP's native 0–100 Effort or WHOOP's 0–21 Day Strain axis.
// Display-only; the stored value never changes, so a flip just re-labels every Effort read-out.
FormRow(label: "Effort scale") {
@@ -1107,6 +1183,41 @@ struct SettingsView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, NoopMetrics.space1)
rowDivider
+ // #1821: sits with Language rather than in Units because it is an app-owned display
+ // CONVENTION, not a unit of measurement — and like Language it offers "System default",
+ // which here means the device's own 24-Hour Time switch rather than the region default.
+ // Unlike Language this needs no relaunch: the formatter caches per resolved template.
+ FormRow(label: "Clock") {
+ Picker("Clock", selection: $clockFormatRaw) {
+ Text("System default").tag(ClockFormatPreference.system.rawValue)
+ Text("12-hour").tag(ClockFormatPreference.twelveHour.rawValue)
+ Text("24-hour").tag(ClockFormatPreference.twentyFourHour.rawValue)
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .tint(StrandPalette.accent)
+ .accessibilityLabel("Clock")
+ // #1829: the resolved clock is memoised, so the write has to drop the memo or the
+ // picker would appear to do nothing until the app restarted.
+ .onChangeCompat(of: clockFormatRaw) { _ in AppClock.invalidate() }
+ }
+ #if os(iOS)
+ rowDivider
+ // #1841: the same preference Android drives its own bar with, by name and meaning. Here
+ // the SYSTEM owns the behaviour — iOS 26 minimises the tab bar to a pill on scroll rather
+ // than sliding it away — so this asks for the platform's reading of the intent rather
+ // than reproducing ours. Below iOS 26 the modifier is inert and the row simply does
+ // nothing, which is why it is not offered there.
+ if #available(iOS 26.0, *) {
+ FormRow(label: "Hide bar when scrolling") {
+ Toggle("", isOn: $bottomBarAutoHide)
+ .labelsHidden()
+ .tint(StrandPalette.accent)
+ .accessibilityLabel("Hide bar when scrolling")
+ }
+ }
+ #endif
+ rowDivider
// Theme presets — one-tap bundles coordinating accent + chart world + backdrop + card
// opacity. Derived (no stored value): tweaking any control below flips this to Custom.
FormRow(label: "Preset") {
@@ -1477,14 +1588,21 @@ struct SettingsView: View {
//
// A live-HR link falls through to the pairing hint when one is set, and otherwise to "Finishing
// the secure pairing handshake…", which is accurate HERE because this platform still retries the
- // CLIENT_HELLO on every connect. Android has an explicit "streaming but not fully paired" arm
- // instead, because #1635 suppression stops it retrying and nothing is finishing any more. If that
- // suppression is ported here, this branch must gain the same arm or it starts describing a
- // handshake that is no longer being attempted.
+ // CLIENT_HELLO on every connect. The #1635 suppression is now ported here too, so once it latches
+ // nothing is finishing any more and the old fall-through would describe a handshake that is no
+ // longer being attempted. The `bonded && connected` arm below is that fix, matching the Android
+ // twin (`SettingsLogic.strapStatusLine`).
if live.encryptedBond && live.connected {
return String(localized: "Your strap is paired and sending data. Open Live for a real-time heart rate.")
}
+ // An actionable hint outranks the generic arm: the suppression hint names the one action that
+ // restores the handshake, which "not fully paired" alone does not.
if live.connected, let hint = live.pairingHint { return hint }
+ // Live HR over the UNBONDED standard profile (#69). True whenever the handshake is suppressed or
+ // simply has not landed, and the honest description either way.
+ if live.bonded && live.connected {
+ return String(localized: "Live heart rate is streaming, but your strap is not fully paired. The encrypted pairing is what carries motion, skin temperature, SpO₂ and respiratory rate — without it, sleep is staged from heart rate alone. Buzz, alarms and history sync need it too.")
+ }
if live.connected { return String(localized: "Connected. Finishing the secure pairing handshake…") }
if live.bonded { return String(localized: "Previously paired but not currently connected. Re-scan to reconnect.") }
return String(localized: "No strap connected. Put your WHOOP nearby and tap Re-scan to pair.")
@@ -1724,7 +1842,8 @@ struct SettingsView: View {
@ViewBuilder private var experimentalCard: some View {
liquidTodayCard
liveSessionsCard
- if showFiveMGControls { fiveMGCard }
+ // WHOOP 5/MG protocol research now lives in Test Centre. Everyday Settings no longer carries
+ // a second copy; the persisted keys and reversible disable actions remain unchanged there.
if showFiveMGControls || model.repo.activeDeviceIsOura { spo2CandidateCard }
sleepStagingCard
rawSensorDiagnosticsCard
@@ -1881,7 +2000,7 @@ struct SettingsView: View {
SettingsSection(
icon: "flask.fill",
title: "Experimental · WHOOP 5 / MG",
- blurb: "Live heart rate already works on a WHOOP 5/MG strap. These probes go further and try to coax more out of it. They are guesses, off by default, and only ever touch a 5/MG strap. WHOOP 4.0 is never affected."
+ blurb: "Normal WHOOP 5/MG recording and history sync are supported. These remaining controls are developer experiments for unmapped protocol features and now live in Test Centre."
) {
VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) {
Toggle(isOn: $puffinExperiments) {
@@ -1910,7 +2029,7 @@ struct SettingsView: View {
// strap kept every flag the enable sequence set while the UI implied it had been undone.
// Now it offers the real undo. Turning it ON still writes nothing until the button is tapped.
.onChangeCompat(of: deepDataEnabled) { on in if !on { confirmingDeepDataDisable = true } }
- Text("WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap — and it is reversible: \u{201C}Turn deep data back off\u{201D} writes the off value to the same flags and then reads every one of them back, so you see what the strap actually stores rather than just that it acked. Experimental: it may do nothing on your firmware. iPhone/Android only. A Mac can't write to a 5/MG.")
+ Text("Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -2565,10 +2684,10 @@ struct SettingsView: View {
}
}
- private func runImport() {
+ private func runImport(allowOversize: Bool = false) {
backupBusy = true
Task {
- let result = await DataBackup.runImport()
+ let result = await DataBackup.runImport(allowOversize: allowOversize)
handleBackup(result)
}
}
@@ -2603,6 +2722,19 @@ struct SettingsView: View {
backupAlertTitle = String(localized: "Backup exported")
backupAlertMessage = String(localized: "Saved to \(url.lastPathComponent). Copy this file to your other \(Platform.deviceNoun) and use Import there to restore everything.")
showBackupAlert = true
+ case .exportedOversize(let url, let bytes, let limit):
+ // #1807: the file is written and worth keeping — say so first, then say what restoring it
+ // will ask for. The old behaviour said nothing here and refused at restore, which is the
+ // one moment the original is already gone.
+ let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
+ let cap = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file)
+ backupAlertTitle = String(localized: "Backup exported")
+ backupAlertMessage = String(localized: "Saved to \(url.lastPathComponent). Your database is \(size), over the \(cap) NOOP restores without asking — the backup is complete and valid, and restoring it will ask you to confirm once.")
+ showBackupAlert = true
+ case .restoreTooLarge(let name, let limit):
+ let cap = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file)
+ oversizeRestoreMessage = String(localized: "\(name) is larger than the \(cap) NOOP restores without asking. That limit guards against a malicious archive expanding to fill this \(Platform.deviceNoun) — a backup you exported yourself is not that. Restoring it needs the space the database will take. You'll be asked to choose the file again.")
+ showOversizeRestoreConfirm = true
case .imported:
backupAlertTitle = String(localized: "Backup imported")
backupAlertMessage = String(localized: "Your data has been restored. Quit and reopen NOOP for it to take effect.")
@@ -2620,9 +2752,7 @@ struct SettingsView: View {
/// project.yml MARKETING_VERSION), so the About pill can never go stale the way a hand-edited
/// Swift constant can. Mirrors how Android's pill reads BuildConfig.VERSION_NAME. Falls back to
/// the hand-maintained changelog version only if the Info.plist key is somehow missing.
- private var bundleVersionString: String {
- (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? AppChangelog.currentVersion
- }
+ private var bundleVersionString: String { UpdateWatch.installedVersion }
private var aboutCard: some View {
SettingsSection(
@@ -2805,6 +2935,23 @@ struct SettingsView: View {
Spacer()
}
+ // #1659: the automatic half. iOS cannot auto-update a sideloaded build at all — no API
+ // lets an app install or re-sign an .ipa — so noticing and saying so is the whole of
+ // what is possible. ON by default, because a sideloaded app has no store to tell the
+ // user anything and a setting nobody finds is the feature not existing; switching it
+ // off here stops the request entirely. See UpdateAvailability.defaultEnabled.
+ Toggle(isOn: $autoCheckUpdates) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Check automatically")
+ .font(StrandFont.subhead)
+ .foregroundStyle(StrandPalette.textPrimary)
+ Text("Once a day, NOOP asks GitHub for the latest version number and puts a note in Updates if there's a newer one. Nothing about you is sent, and it never installs anything.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textSecondary)
+ }
+ }
+ .tint(StrandPalette.accent)
+
// Update available: show what's new, with a download straight to the release.
if case .available(let v, let url, let notes) = updateChecker.state {
VStack(alignment: .leading, spacing: 8) {
@@ -3267,6 +3414,15 @@ struct StepsCalibrationSheet: View {
@State private var draftManual: Double = 0
@State private var didLoad = false
+ /// The strap has banked no motion, and we have looked.
+ ///
+ /// Named once because two places depend on it and they must stay exactly complementary: the
+ /// no-motion banner appears, and the calibration countdown does NOT. Written as two separate
+ /// expressions they drifted immediately — the guard's first draft tested `sampleMotion == nil`
+ /// alone, which is also true during the load, so the countdown vanished in a window where the
+ /// banner had not appeared yet and the card explained nothing at all.
+ private var strapHasNoMotion: Bool { didLoad && sampleMotion == nil }
+
/// #107: the sheet's guidance depends on the strap family. A WHOOP 4.0 streams motion automatically, so
/// "let it sync" is right; a 5/MG only streams motion once the experimental deep-data unlock is on, so
/// the 4.0 advice is futile there and the empty state must say so instead.
@@ -3287,7 +3443,7 @@ struct StepsCalibrationSheet: View {
ScrollView {
VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) {
explainerCard
- if didLoad && sampleMotion == nil { noMotionNote }
+ if strapHasNoMotion { noMotionNote }
currentFitCard
comparisonCard
manualAdjustCard
@@ -3358,7 +3514,7 @@ struct StepsCalibrationSheet: View {
.font(StrandFont.headline)
.foregroundStyle(StrandPalette.textPrimary)
Text(is5MG
- ? String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter — a WHOOP 5.0 / MG streams motion (not a step count) only with deep data on.")
+ ? String(localized: "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.")
: String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter. A WHOOP 4.0 doesn't transmit steps."))
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textSecondary)
@@ -3399,7 +3555,7 @@ struct StepsCalibrationSheet: View {
/// The "why it's empty" line — a 5/MG needs the deep-data unlock before it streams motion at all.
private var noMotionLead: String {
if is5MG {
- return String(localized: "We're not seeing any motion from your WHOOP 5.0 / MG yet. Unlike a 4.0, a 5/MG only streams motion (and history) once the experimental deep-data unlock is on — so until then there's nothing to estimate steps from. Importing history from WHOOP or Apple Health doesn't provide the strap motion this needs.")
+ return String(localized: "We're not seeing motion from your WHOOP 5.0 / MG yet. Keep NOOP connected and let strap history finish syncing; the experimental R22 flags are not required. Account or Apple Health imports do not contain the raw strap motion this estimate needs.")
}
return String(localized: "We're not seeing any motion from your strap yet. Steps are estimated from your WHOOP's banked motion history, so your strap needs to sync that history before NOOP has anything to count.")
}
@@ -3407,7 +3563,7 @@ struct StepsCalibrationSheet: View {
/// The "what to do" line — 5/MG points at the deep-data toggle (unless it's already on, then just sync).
private var noMotionAction: String {
if is5MG && !deepDataEnabled {
- return String(localized: "Turn on Settings → \u{201C}Unlock WHOOP 5/MG deep data (R22)\u{201D}, reconnect your strap, then open NOOP near it and let a day or two of motion sync. Your step estimate and the calibration below fill in once motion lands.")
+ return String(localized: "Open NOOP near the strap and let WHOOP 5/MG history finish syncing. The step estimate and calibration fill in once enough stored motion has arrived; the legacy R22 experiment is not required.")
}
if is5MG {
return String(localized: "Deep data is on — open NOOP near your strap and let it sync its motion history (a full first-run sync can take a while). Once a day or two of motion lands, your step estimate and the calibration below fill in.")
@@ -3445,6 +3601,18 @@ struct StepsCalibrationSheet: View {
Text("Not calibrated yet")
.font(StrandFont.bodyNumber)
.foregroundStyle(StrandPalette.textPrimary)
+ // Only ask for phone-step days when phone-step days are what is actually missing.
+ //
+ // A step estimate is `motion * coefficient` (`StepsEstimateEngine.estimate`) and a
+ // calibration point is the ratio `steps / motion`, so BOTH halves are required. With no
+ // banked strap motion neither the estimate nor the fit can move however many days the
+ // phone counts. The countdown below then names the half the user already has and hides
+ // the half they do not — a field report asked whether entering Apple Health steps by
+ // hand would start the calibration, which is exactly the conclusion it invites.
+ //
+ // The no-motion banner at the top of this sheet already explains the real blocker, so
+ // the honest move is to stop competing with it rather than to add more copy.
+ if !strapHasNoMotion {
// #589: a concrete countdown instead of a vague "a few days". Headline comes straight
// from the engine's needsMoreDays state so the wording matches the Today steps tile.
// #693: drive `have` off `profile.stepsCalibrationSampleDays` — the value the engine
@@ -3462,6 +3630,7 @@ struct StepsCalibrationSheet: View {
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
+ }
}
}
}
@@ -3613,7 +3782,7 @@ struct StepsCalibrationSheet: View {
.sorted { $0.day > $1.day }
// Reconstruct the estimate for the most recent phone-covered days, motion-by-motion.
- guard coeff > 0, let store = await repo.storeHandle() else { return }
+ guard coeff > 0 else { return }
let cal = StepsEstimateEngine.Calibration(coefficient: coeff,
sampleDays: profile.stepsCalibrationSampleDays,
confidence: profile.stepsCalibrationConfidence,
@@ -3625,8 +3794,10 @@ struct StepsCalibrationSheet: View {
for entry in phoneDays.prefix(10) { // scan a few extra to fill 7 after motion gaps
guard let dayDate = dayParser.date(from: entry.day) else { continue }
let mid = Int(calendar.startOfDay(for: dayDate).timeIntervalSince1970)
- let grav = (try? await store.gravitySamples(deviceId: repo.deviceId, from: mid,
- to: mid + 86_400 - 1, limit: 200_000)) ?? []
+ // #1643: the UNION, not `repo.deviceId` alone — a re-added strap leaves motion under both the
+ // active id and the canonical one, and reading either by itself makes this screen disagree
+ // with the estimator it is supposed to be reconstructing.
+ let grav = await repo.gravitySamplesUnion(from: mid, to: mid + 86_400 - 1)
let motion = StepsEstimateEngine.dayMotionIntensity(grav)
guard motion > 0, let est = StepsEstimateEngine.estimate(motion: motion, calibration: cal) else { continue }
motions.append(motion)
diff --git a/Strand/Screens/SkinTempCardsView.swift b/Strand/Screens/SkinTempCardsView.swift
index 9c1c886264..7852ad3ca0 100644
--- a/Strand/Screens/SkinTempCardsView.swift
+++ b/Strand/Screens/SkinTempCardsView.swift
@@ -759,11 +759,7 @@ struct BodyClockCard: View {
components.hour = hh
components.minute = mm
guard let date = components.date else { return "\(hh):\(String(format: "%02d", mm))" }
- let formatter = DateFormatter()
- formatter.locale = AppLanguage.activeLocale
- formatter.dateStyle = .none
- formatter.timeStyle = .short
- return formatter.string(from: date)
+ return AppClock.hourMinute(date) // #1821
}
}
diff --git a/Strand/Screens/SleepCustomizationSheet.swift b/Strand/Screens/SleepCustomizationSheet.swift
index 763d0e01fb..6840bed5a8 100644
--- a/Strand/Screens/SleepCustomizationSheet.swift
+++ b/Strand/Screens/SleepCustomizationSheet.swift
@@ -94,6 +94,7 @@ extension SleepSection {
switch self {
case .sleepMarks: return "bed.double"
case .stages: return "chart.bar.xaxis"
+ case .bodyClock: return "clock.badge.checkmark"
case .nightDetail: return "square.grid.2x2"
case .sleepDebt: return "arrow.down.right.circle"
case .stagesVsTypical: return "chart.bar"
@@ -107,6 +108,7 @@ extension SleepSection {
switch self {
case .sleepMarks: return StrandPalette.accent
case .stages: return StrandPalette.restColor
+ case .bodyClock: return StrandPalette.restColor
case .nightDetail: return StrandPalette.restBright
case .sleepDebt: return StrandPalette.effortColor
case .stagesVsTypical: return StrandPalette.restColor
diff --git a/Strand/Screens/SleepDebtLedgerCard.swift b/Strand/Screens/SleepDebtLedgerCard.swift
index 6301394dd8..e53f99069a 100644
--- a/Strand/Screens/SleepDebtLedgerCard.swift
+++ b/Strand/Screens/SleepDebtLedgerCard.swift
@@ -7,14 +7,14 @@ import WhoopStore
//
// The Sleep tab's "Sleep-debt ledger" card, extracted into a standalone view so it can ALSO be hosted in
// the Today tab. Both the Sleep tab and the Today host render THIS view from the SAME `SleepModel`, so the
-// rolling 14-night running balance can never diverge between the two surfaces (the parity contract). The
+// recency-weighted estimate can never diverge between the two surfaces (the parity contract). The
// card body + its `debtDeltaBars` strip and the debt-only formatting helpers (`debtHeadline` /
// `debtTag` / `debtRead` / `debtBalanceColor` / `debtSigned`) are a verbatim lift of the former
// `SleepView.sleepDebtLedger` (and the helpers only it used); the nap-credited ledger is computed once in
// `SleepModel.build` (the shared builder) and read here — it is NOT recomputed.
-/// The "Sleep-debt ledger" card. A running balance of (slept − personal need) across the recent
-/// fortnight — the net debt/surplus headline, a plain-English read, and a diverging per-night delta bar —
+/// The "Sleep-debt ledger" card. A recency-weighted estimate across the recent fortnight — the
+/// current debt headline, a plain-English read, and a diverging raw per-night delta bar —
/// rendered from the shared [SleepModel]'s `sleepDebtLedger`. (#242)
struct SleepDebtLedgerCard: View {
let model: SleepModel
@@ -52,7 +52,8 @@ struct SleepDebtLedgerCard: View {
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
- // Per-night diverging delta bars (surplus up, deficit down).
+ // Raw per-night delta bars (above/below base need), intentionally separate
+ // from the recurrence's current debt estimate.
debtDeltaBars(ledger)
Divider().overlay(StrandPalette.hairline)
ChartFooter([
@@ -112,30 +113,30 @@ struct SleepDebtLedgerCard: View {
return "≈\(durationText(m))"
}
- /// Short tag under/beside the headline: DEBT / SURPLUS / ON TARGET.
+ /// Short tag under/beside the headline: DEBT / ON TARGET.
private func debtTag(_ ledger: SleepDebtLedger) -> String {
if ledger.magnitudeMin < SleepDebt.onTargetBandMin { return String(localized: "balanced") }
- return ledger.isDebt ? String(localized: "sleep debt") : String(localized: "surplus")
+ return ledger.isDebt ? String(localized: "sleep debt") : String(localized: "balanced")
}
- /// Plain-English read of the running balance over the window.
+ /// Plain-English read of the current actionable estimate.
private func debtRead(_ ledger: SleepDebtLedger) -> String {
let nights = ledger.nightCount
let span = nights == 1
? String(localized: "the last night")
: String(localized: "the last \(nights) nights")
if ledger.magnitudeMin < SleepDebt.onTargetBandMin {
- return String(localized: "You're roughly on top of your sleep across \(span). Slept minutes balance out against your need.")
+ return String(localized: "You've effectively met your current sleep need across \(span).")
}
let mag = durationText(ledger.magnitudeMin)
if ledger.isDebt {
- return String(localized: "You've banked about \(mag) of sleep debt over \(span). Surplus nights count back against it. An earlier night or two would clear it.")
+ return String(localized: "Add about \(mag) to your base sleep target tonight. Meeting that complete sleep need clears the displayed debt; recent shortfalls carry forward at a reduced weight.")
}
- return String(localized: "You're carrying about \(mag) of surplus over \(span). You've slept past your need on balance. Nicely ahead.")
+ return String(localized: "You've effectively met your current sleep need across \(span).")
}
- /// Color the balance by sign + size: surplus/within-band → positive green, modest
- /// debt → warning, heavier debt → critical.
+ /// Color the balance by size: balanced → positive green, modest debt → warning,
+ /// heavier debt → critical.
private func debtBalanceColor(_ ledger: SleepDebtLedger) -> Color {
if ledger.magnitudeMin < SleepDebt.onTargetBandMin || !ledger.isDebt {
return StrandPalette.statusPositive
diff --git a/Strand/Screens/SleepModel.swift b/Strand/Screens/SleepModel.swift
index 493b3f56c7..198035ed06 100644
--- a/Strand/Screens/SleepModel.swift
+++ b/Strand/Screens/SleepModel.swift
@@ -118,12 +118,12 @@ struct Night {
// Clock for the Asleep/Woke row — the times people read at a glance. The "jmm" skeleton
// follows the device's 12-/24-hour setting ("11:42 PM" or "23:42") instead of forcing one
// on everyone, matching the HR-tooltip / workout times (#337).
- private static let timeFmt: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.setLocalizedDateFormatFromTemplate("jmm")
- return f
- }()
+ /// #1821: routed through `AppClock` so the Clock format setting reaches every night time on screen -
+ /// this is the formatter behind the sleep card's ASLEEP/WOKE values in the report. It used the `jmm`
+ /// template, whose `j` resolves the hour from the locale, which is how a reader's explicit 12-hour
+ /// choice was being discarded. `AppClock` caches per resolved template, so this is no more expensive
+ /// than the `static let` it replaces and it responds when the setting changes.
+ private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() }
private static let dateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE d MMM"; return f
}()
@@ -137,8 +137,42 @@ struct Night {
/// `SleepModel.build(_:)` and read by the subviews, so full passes over the day rows / sleep sessions
/// and the Night.intervals reconstruction no longer run on every render.
struct SleepModel {
- /// (latest, typical mean, full history) per metric — mirrors SleepView's per-tile series.
- typealias Metric = (latest: Double?, typical: Double?, series: [Double])
+ /// (latest, latestDay, typical mean, full history) per metric — mirrors SleepView's per-tile series.
+ /// `latestDay` is the yyyy-MM-dd the `latest` value was carried from (nil when there is no latest
+ /// value, or when the latest value IS today's). #1946: a carried prior-day value is stamped with its
+ /// day so it is not passed off as tonight's read.
+ typealias Metric = (latest: Double?, latestDay: String?, typical: Double?, series: [Double])
+
+ /// #1946: the caption for a metric tile whose `latest` value was carried from a prior day.
+ /// Returns nil when the value is NOT carried (today's own, or no value) so the caller falls through
+ /// to the normal "vs typical" caption. When carried, returns "Carried · " so a prior night's
+ /// number is never passed off as tonight's read. Pure + unit-testable. Mirror EXACTLY in Kotlin.
+ static func carriedMetricCaption(latestDay: String?, latest: Double?) -> String? {
+ guard let latestDay, latest != nil else { return nil }
+ return String(localized: "Carried · \(Self.shortDayLabel(latestDay))")
+ }
+
+ /// "12 Jul" for a "yyyy-MM-dd" key — the SAME format `TodayView.carriedCaption` uses for the
+ /// recovery carry stamp, so a carried Rest on Today and a carried metric on Sleep read identically.
+ static func shortDayLabel(_ key: String) -> String {
+ guard let date = dayKeyParser.date(from: key) else { return key }
+ return Self.shortDayFormatter.string(from: date)
+ }
+
+ private static let dayKeyParser: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.timeZone = TimeZone(identifier: "UTC")
+ return f
+ }()
+
+ private static let shortDayFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.locale = AppLanguage.activeLocale
+ f.setLocalizedDateFormatFromTemplate("dMMM")
+ return f
+ }()
let night: Night
/// Stage intervals for the hypnogram — computed once (Night.intervals is a computed
@@ -169,8 +203,8 @@ struct SleepModel {
let trendPoints: [TrendPoint]
- /// Rolling 14-night sleep-debt ledger: Σ(slept − personal need) across the recent
- /// fortnight, with the per-night deltas behind it. Computed once per data change.
+ /// Recency-weighted sleep-debt estimate across the latest 14 usable nights, with
+ /// raw per-night deltas for context. Computed once per data change.
let sleepDebtLedger: SleepDebtLedger
}
@@ -365,7 +399,11 @@ extension SleepModel {
// `BodyVitalSigns.logicalDayKey` rather than `Repository.logicalDayKey`: same 04:00 boundary,
// but self-contained, so this stays pure and independent of the @MainActor Repository.
let fresh = Baselines.freshestCarried(points, todayKey: BodyVitalSigns.logicalDayKey(now))
- return (fresh?.value, mean(series), series)
+ // #1946: track the day the carried value came from so the tile can stamp it. nil when there is
+ // no carried value, or when the carried value IS today's own (no stamp needed for today's read).
+ let todayKey = BodyVitalSigns.logicalDayKey(now)
+ let latestDay = (fresh != nil && fresh!.day != todayKey) ? fresh!.day : nil
+ return (fresh?.value, latestDay, mean(series), series)
}
/// Sleep performance %: the imported WHOOP figure when the export carried one for that day;
@@ -392,7 +430,7 @@ extension SleepModel {
let imported = importedSleep
if let lastDay = days.last?.day, imported[lastDay]?.consistencyPct != nil {
let series = days.compactMap { imported[$0.day]?.consistencyPct }
- return (series.last, mean(series), series)
+ return (series.last, nil, mean(series), series)
}
let cal = Calendar.current
func bedMinutes(_ s: CachedSleepSession) -> Double {
@@ -403,7 +441,7 @@ extension SleepModel {
return m
}
let mins = sleeps.map(bedMinutes)
- guard mins.count >= 3 else { return (nil, nil, []) }
+ guard mins.count >= 3 else { return (nil, nil, nil, []) }
var scores: [Double] = []
for i in mins.indices {
let lo = Swift.max(0, i - 13)
@@ -414,7 +452,7 @@ extension SleepModel {
let sd = variance.squareRoot()
scores.append(Swift.max(0, Swift.min(100, 100 * (1 - sd / 120))))
}
- return (scores.last, mean(scores), scores)
+ return (scores.last, nil, mean(scores), scores)
}
/// Hours vs needed % = asleep / need. The imported sleep_need_min wins per day; else the
@@ -443,20 +481,23 @@ extension SleepModel {
metric(days: days) { $0.respRateBpm }
}
- /// Sleep debt (minutes): the imported sleep_debt_min when the export carried it; else the
- /// APPROXIMATE per-night need − (main sleep + nap sleep), floored at 0.
+ /// Sleep debt (minutes): imported `sleep_debt_min` remains export-verbatim. Otherwise use
+ /// the same recency-weighted ledger as the card, including nap credit and its 14-night window.
static func sleepDebtSeries(days: [DailyMetric], importedSleep: [String: ImportedSleepFigures],
napSleepMinByDay: [String: Double]) -> Metric {
- let imported = importedSleep
let need = debtNeedMin(days: days) // #242: normative need, not the self-referential mean
- let series = days.compactMap { d -> Double? in
- if let debt = imported[d.day]?.debtMin { return debt } // minutes, export-verbatim
- guard let asleep = SleepDebt.creditedSleepMin(
+ let credited = days.map { d in
+ (day: d.day, totalSleepMin: SleepDebt.creditedSleepMin(
mainSleepMin: d.totalSleepMin,
- napSleepMin: napSleepMinByDay[d.day] ?? 0), need > 0 else { return nil }
- return Swift.max(0, need - asleep) // APPROXIMATE fallback
+ napSleepMin: napSleepMinByDay[d.day] ?? 0))
}
- return (series.last, mean(series), series)
+ let importedDebt = importedSleep.compactMapValues(\.debtMin)
+ let series = SleepDebt.debtSeries(
+ series: credited,
+ needHours: need / 60.0,
+ importedDebtMin: importedDebt
+ ).map(\.value)
+ return (series.last, nil, mean(series), series)
}
// MARK: Trend points
@@ -469,10 +510,9 @@ extension SleepModel {
// MARK: Sleep-debt ledger
- /// The rolling 14-night sleep-debt ledger from the cached daily metrics. Measures against the
- /// normative `debtNeedMin` (the engine's `personalizedNeedHours`) — the SAME need the per-night
- /// "Sleep Debt" tile uses, so the running-balance card and the tile agree — over each main night's
- /// `totalSleepMin` plus actual asleep minutes from separately-recorded naps. (#242)
+ /// The 14-night recency-weighted estimate from cached daily metrics. It measures against the
+ /// normative `debtNeedMin` (the engine's `personalizedNeedHours`) — the SAME need and recurrence
+ /// the local "Sleep Debt" tile uses — and credits actual asleep minutes from separate naps. (#242)
static func debtLedger(days: [DailyMetric], napSleepMinByDay: [String: Double]) -> SleepDebtLedger {
SleepDebt.ledger(
series: days.map { day in
diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift
index 32ff136edd..135de4c823 100644
--- a/Strand/Screens/SleepView.swift
+++ b/Strand/Screens/SleepView.swift
@@ -28,6 +28,9 @@ import UIKit
struct SleepView: View {
@EnvironmentObject var repo: Repository
+ /// For `circadianPhase` only (the body-clock dial). Named `appModel` because `model` on this screen is
+ /// already the built `SleepModel`.
+ @EnvironmentObject var appModel: AppModel
// NOTE: SleepView itself deliberately does NOT observe `LiveState`. A connected strap publishes
// at ~1 Hz; observing here would re-evaluate this heavy body on every tick. The only two live
// dependencies — the "going to sleep / awake" mark card (it appends to the strap log) and the
@@ -72,7 +75,7 @@ struct SleepView: View {
@State private var habitualMidsleepSec: Int? = nil
/// Persisted per-epoch MOTION series keyed by each session's detected `startTs` (#407). Loaded in the
- /// same `.task` as `allSessions` from `repo.sessionMotions(starts:)`, then laid along the hypnogram for
+ /// same `.task` as `allSessions` from `repo.sessionMotions(sessions:)`, then laid along the hypnogram for
/// the SAME main-night GROUP blocks the hero resolved (mergeDay's group) — we do NOT re-resolve the
/// night, only read the already-chosen group's stored motion. A block with no stored series stays absent
/// (honest empty state for older rows whose `motionJSON` is NULL). Refreshed with `allSessions`.
@@ -151,6 +154,7 @@ struct SleepView: View {
// Each top-level section fades + rises in sequence on first appear (Reduce-Motion safe).
VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) {
if let sleepUndo { sleepUndoBanner(sleepUndo) }
+ SleepFreshnessNote(latestWakeTs: resolved.night.session.endTs)
// Bleed past ScreenScaffold's 16/24 gutters so the hero column is edge-to-edge
// in the upper band; the night scene itself is the fixed topBackground.
// Customize sits at the end of the hero (not floating in a blank band).
@@ -205,7 +209,7 @@ struct SleepView: View {
habitualMidsleepSec = await repo.habitualMidsleepSec()
// Per-epoch motion for every block (#407), keyed by detected start. mergeDay reads only the
// already-resolved group's entries — this just pre-fetches them all so the model build is sync.
- motionByStart = await repo.sessionMotions(starts: allSessions.map { $0.startTs })
+ motionByStart = await repo.sessionMotions(sessions: allSessions)
nightOffset = 0
navNight = nil
modelKey = dataKey
@@ -306,7 +310,8 @@ struct SleepView: View {
/// Locale-formatted clock time (no date) for the banner's window range.
private func clockTime(_ ts: Int) -> String {
Date(timeIntervalSince1970: TimeInterval(ts))
- .formatted(date: .omitted, time: .shortened)
+ .formatted(Date.FormatStyle(date: .omitted, time: .shortened)
+ .locale(AppClock.formattingLocale)) // #1821
}
/// The transient undo strip: a Rest-tinted frosted banner with the suppressed window and a real Undo
@@ -356,22 +361,25 @@ struct SleepView: View {
return n == 0 ? "Last night" : (n == 1 ? "1 night ago" : "\(n) nights ago")
}
- /// #1311: how many CALENDAR nights back the carousel night at `offset` is from the newest recorded
- /// night. The ◀/▶ carousel steps by RECORDED night (`navDays`, newest-first), so a night with no
- /// data (strap off-body) is a gap the flat index can't see — labelling by index makes two nights
- /// either side of a skipped night read as consecutive and desyncs the "N nights ago" labels (and the
- /// Rest value they name). Uses the same local start-of-day `navDays` is grouped by; falls back to the
- /// raw index if it can't resolve. 0 = last night. Mirrors Android SleepHeroLogic.calendarNightsAgo.
- private func nightsAgo(_ offset: Int) -> Int {
- let days = navDays
- guard offset >= 0, offset < days.count,
- let newestTs = days.first?.first?.endTs, let shownTs = days[offset].first?.endTs
- else { return offset }
- let cal = Calendar.current
- let shown = cal.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(shownTs)))
- let newest = cal.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(newestTs)))
- let d = cal.dateComponents([.day], from: shown, to: newest).day ?? offset
- return d >= 0 ? d : offset
+ /// #1311: how many nights back the carousel night at `offset` is, counted in CALENDAR nights rather
+ /// than carousel index. The ◀/▶ carousel steps by RECORDED night (`navDays`, newest-first), so a
+ /// night with no data (strap off-body) is a gap the flat index can't see — labelling by index makes
+ /// two nights either side of a skipped night read as consecutive and desyncs the "N nights ago"
+ /// labels (and the Rest value they name).
+ ///
+ /// Counted FROM TODAY, not from the newest recorded night.
+ ///
+ /// Delegates to `SleepNightLabel.nightsAgo`, which is where this logic is tested. It lived inline
+ /// and private here, which is why the newest-anchored defect went uncaught on this platform.
+ /// Kotlin twin: `calendarNightsAgo`.
+ private func nightsAgo(_ offset: Int, now: Date = Date()) -> Int {
+ SleepNightLabel.nightsAgo(
+ // Optional per entry, NOT `?? 0`: a day group with no session must fall back to the offset
+ // the way the Kotlin twin does. Zero would be 1970 and would read as ~20,000 nights ago.
+ wakeTimestamps: navDays.map { $0.first.map { s in Int(s.endTs) } },
+ offset: offset,
+ today: Repository.logicalDay(now)
+ )
}
/// The night the Rest hero reflects: the ◀/▶-navigated night while browsing (falling back to
@@ -401,6 +409,7 @@ struct SleepView: View {
switch section {
case .sleepMarks: SleepMarkCard()
case .stages: hero(model)
+ case .bodyClock: bodyClockDial(model)
case .nightDetail: NightDetailCard(model: model)
case .sleepDebt: SleepDebtLedgerCard(model: model)
case .stagesVsTypical: StagesVsTypicalCard(model: model)
@@ -408,6 +417,28 @@ struct SleepView: View {
}
}
+ /// The 24 h dial (#1680), or nothing at all.
+ ///
+ /// Drawn only for a fit that is at least `.wide`: an `.unreadable` rhythm has no phase to compare a
+ /// night against, and an empty ring would read as a broken chart rather than as "not enough data". The
+ /// card is a reorderable Sleep section, so anyone who does not want it hides it in Arrange — the same
+ /// affordance every other card on this screen already has, rather than a new setting of its own.
+ @ViewBuilder
+ private func bodyClockDial(_ model: SleepModel) -> some View {
+ if let phase = appModel.circadianPhase, phase.confidence != .unreadable {
+ BodyClockDialCard(estimate: phase,
+ actualBedHour: Self.localClockHour(model.night.session.effectiveStartTs),
+ actualWakeHour: Self.localClockHour(model.night.session.endTs))
+ }
+ }
+
+ /// A unix second as a fractional local clock hour — the dial's only input beyond the phase estimate.
+ static func localClockHour(_ ts: Int) -> Double {
+ let c = Calendar.current.dateComponents([.hour, .minute],
+ from: Date(timeIntervalSince1970: TimeInterval(ts)))
+ return Double(c.hour ?? 0) + Double(c.minute ?? 0) / 60.0
+ }
+
/// The compact "Customize" affordance above the arrangeable cards — opens the Arrange sheet. Mirrors
/// the Today tab's arrange entry and the Android Sleep affordance.
private var sleepArrangeAffordance: some View {
@@ -793,6 +824,14 @@ struct SleepView: View {
if stageStagingIsSparse(night) {
stageIncompleteNote
}
+ // #1716 — a device-provided hypnogram assembled from records that never all arrived leaves a
+ // HOLE in the timeline while the session still spans the whole night, so a night we saw a
+ // fraction of renders as a complete one. Say which fraction. This is the only place the
+ // coverage guard becomes visible: the engine's matching Rest downgrade lands in a transient
+ // `DayResult` field no screen reads, so the gate was otherwise correct and inert.
+ if let coverage = stageCoverage(night), coverage < HypnogramCoverage.minCoverage {
+ stagePartialNote(coverage)
+ }
// For an Oura-provided night, say plainly that this split is the ring's RAW on-device
// classification — so the larger Awake / smaller Deep+REM here isn't misread as the polished
// numbers the Oura app shows for the same night (the app post-processes the same stream).
@@ -918,6 +957,17 @@ struct SleepView: View {
night.sourceBlocks.contains { $0.stagingSparse == true }
}
+ /// How much of this night's window its stage timeline actually accounts for, or nil when coverage is
+ /// not a measurable question for the payloads it was built from (#1716). Asked of the bridged main-night
+ /// GROUP via the SAME shared accumulation `analyzeDay` uses, threading the same learned habitual so the
+ /// group resolves identically to the hero's — a per-row answer would be the wrong question for a
+ /// fragmented night. Mirror in Kotlin.
+ private func stageCoverage(_ night: Night) -> Double? {
+ let group = SleepView.mainNightGroup(night.sourceBlocks,
+ habitualMidsleepSec: night.habitualMidsleepSec)
+ return HypnogramCoverage.groupFraction(group.isEmpty ? night.sourceBlocks : group)
+ }
+
/// Pure H9 gate (unit-testable without a live view) — true when a night's staging is low-confidence:
/// a high-efficiency night whose deep+REM share is below the restorative floor. Built on the engine's
/// own `ScoreConfidence.rest(...)` so the UI flag and the persisted Rest confidence agree. `asleepMin`,
@@ -977,6 +1027,35 @@ struct SleepView: View {
.accessibilityElement(children: .combine)
}
+ /// The PARTIAL-TIMELINE caveat (#1716): this night's stage segments account for less than
+ /// `HypnogramCoverage.minCoverage` of the window the session claims, so the stage totals describe only
+ /// the part of the night the timeline accounts for. Distinct from BOTH notes above — H9 doubts the
+ /// deep/REM SPLIT of a fully-described night, #345 doubts a night staged on thin motion, and this one
+ /// says plainly that some of the night is MISSING rather than doubted. It is the visible half of the
+ /// engine-side guard: `analyzeDay` already downgrades Rest to `.building` on exactly this condition,
+ /// but that tier is transient engine output no screen reads, so without this the gate was inert.
+ ///
+ /// HONEST-DATA: it reports only what was observed and changes no number. The percentage is floored,
+ /// never rounded — 94.8% must not print as "95%" and appear to contradict the gate that flagged it.
+ /// The copy names NO cause and offers NO remedy, deliberately: on the 08-29/30 and 08-30/31 captures the
+ /// missing codes DID reach NOOP — the ring reported them unwritten (0xFF), the persist log trimmed
+ /// exactly as many as the hole is wide — and re-persisting the same night 5 and 8 times left the hole
+ /// intact. "The rest never reached NOOP" and "syncing again can fill in" were both wrong. Nor does the
+ /// copy point at the totals by DIRECTION: both hosts render this note below the stage-breakdown card
+ /// that carries them, so "the totals below" pointed the wrong way on every screen that shipped it.
+ private func stagePartialNote(_ coverage: Double) -> some View {
+ let pct = Int((coverage * 100).rounded(.down))
+ return HStack(alignment: .top, spacing: 8) {
+ SourceBadge("Partly recorded", tint: StrandPalette.statusWarning)
+ Text("Only \(pct)% of this night's window has stage data. The stage totals cover only that part of the night.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.horizontal, 2)
+ .accessibilityElement(children: .combine)
+ }
+
/// Honest caveat for an Oura-provided night: the stage split shown here is the ring's RAW on-device
/// SleepNet classification, read straight off the BLE phase stream — NOT the adjusted stages the Oura
/// app displays. The app post-processes the same night, so its Deep/REM run higher and its Awake lower;
@@ -1265,9 +1344,9 @@ struct SleepView: View {
// MARK: - WHOOP stage-timeline rows (the sleep-details reference design, ryanAtriumAi #988)
/// Clock labels for the timeline axis; "jmm" respects the device 12/24-hour setting.
- private static let stageAxisFormatter: DateFormatter = {
- let f = DateFormatter(); f.locale = AppLanguage.activeLocale; f.setLocalizedDateFormatFromTemplate("jmm"); return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var stageAxisFormatter: DateFormatter { AppClock.hourMinuteFormatter() }
/// The WHOOP sleep-stages chart: a stack of four per-stage timeline rows (AWAKE · LIGHT ·
/// DEEP · REM, WHOOP's order) over a shared onset→wake time axis. Each row is independently
@@ -1976,10 +2055,7 @@ struct SleepView: View {
@ViewBuilder
private var emptyState: some View {
- // While the strap is mid-offload, say so — "No nights" reads as final otherwise (#77). The note
- // owns the `LiveState` observation in its own leaf so the chunk count ticks without re-rendering
- // SleepView (scroll-stutter isolation; identical output to the prior inline check).
- SleepSyncingNote()
+ SleepFreshnessNote(latestWakeTs: nil)
if repo.loaded {
ComingSoon(what: "No nights here yet. Import your WHOOP export in Data Sources to see every night, your sleep stages and trends straight away. Or open Intelligence to see last night computed from the strap after you wear it to bed.")
} else {
@@ -2482,10 +2558,72 @@ struct SleepMarkCard: View {
/// The "Syncing strap history…" note, shown only while a historical offload is running (#77). Owns the
/// `LiveState` observation so the chunk count ticks without re-rendering the rest of the Sleep screen.
-private struct SleepSyncingNote: View {
+enum SleepFreshnessStatus: Equatable {
+ case syncing, calculating, syncFailed, awaitingSync, notDetected
+}
+
+/// Pure priority ladder behind the Sleep status banner. "Missing" is deliberately held until morning so
+/// opening Sleep during the night does not claim a still-in-progress night was missed.
+func resolveSleepFreshness(hasCurrentNight: Bool, morningReady: Bool, syncing: Bool,
+ calculating: Bool, syncedSinceDayStart: Bool,
+ syncFailed: Bool) -> SleepFreshnessStatus? {
+ if syncing { return .syncing }
+ if calculating { return .calculating }
+ if hasCurrentNight || !morningReady { return nil }
+ if syncFailed { return .syncFailed }
+ return syncedSinceDayStart ? .notDetected : .awaitingSync
+}
+
+/// Explicit state for the expected current night. Older sleep can remain available underneath, but it is
+/// never left to impersonate today's result while a sync, calculation, or failed detection is unresolved.
+private struct SleepFreshnessNote: View {
@EnvironmentObject private var live: LiveState
+ @EnvironmentObject private var intelligence: IntelligenceEngine
+ let latestWakeTs: Int?
+
var body: some View {
- if live.backfilling { SyncingHistoryNote(chunks: live.syncChunksThisSession) }
+ let calendar = Calendar.current
+ let now = Date()
+ let start = calendar.startOfDay(for: now)
+ let current = latestWakeTs.map {
+ calendar.isDate(Date(timeIntervalSince1970: TimeInterval($0)), inSameDayAs: now)
+ } ?? false
+ // AppModel intentionally waits two quiet seconds after HISTORY_COMPLETE before starting the
+ // scoring pass. Treat that debounce as calculation too; otherwise the banner can flash the final
+ // "wasn't detected" verdict between sync completion and `intelligence.computing` becoming true.
+ let calculationQueued = live.lastSyncedAt.map {
+ (0..<5).contains(now.timeIntervalSince1970 - $0)
+ } ?? false
+ let status = resolveSleepFreshness(
+ hasCurrentNight: current,
+ morningReady: calendar.component(.hour, from: now) >= 6,
+ syncing: live.backfilling,
+ calculating: intelligence.computing || calculationQueued,
+ syncedSinceDayStart: (live.lastSyncedAt ?? 0) >= start.timeIntervalSince1970,
+ syncFailed: live.lastSyncError != nil
+ )
+ switch status {
+ case .syncing:
+ SyncingHistoryNote(chunks: live.syncChunksThisSession)
+ case .calculating:
+ DataPendingNote(title: "Calculating last night's sleep…",
+ message: "Your strap history is in. NOOP is detecting and staging the night now.",
+ symbol: "waveform.path.ecg")
+ case .syncFailed:
+ DataPendingNote(title: "Last night's sleep hasn't synced",
+ message: "The history sync stopped before it finished. Keep the strap nearby and try Sync again.",
+ symbol: "exclamationmark.arrow.triangle.2.circlepath")
+ case .awaitingSync:
+ DataPendingNote(title: "Waiting for last night's sleep",
+ message: "Connect the strap and sync its history. NOOP will calculate the night when the overnight data arrives.",
+ symbol: "arrow.triangle.2.circlepath")
+ case .notDetected:
+ DataPendingNote(title: "Last night's sleep wasn't detected",
+ message: "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep.",
+ symbol: "moon.zzz")
+ case nil:
+ EmptyView()
+ }
}
}
diff --git a/Strand/Screens/SmartAlarmView.swift b/Strand/Screens/SmartAlarmView.swift
index f69c181abf..86ab5ab6c4 100644
--- a/Strand/Screens/SmartAlarmView.swift
+++ b/Strand/Screens/SmartAlarmView.swift
@@ -242,6 +242,27 @@ struct SmartAlarmView: View {
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
+ // #1706: ask the strap what it actually has stored. The readback was otherwise
+ // only reachable by ARMING, so anyone whose alarm is off could not produce the
+ // frame that explains a wrong reported time.
+ //
+ // Shown unconditionally, where the Android twin hides it until bonded. That is a
+ // deliberate platform difference, not drift: the Android card already observes
+ // LiveState, so gating there is free, while this view does not — and pulling
+ // `live` in would re-render the whole alarm screen on every 1 Hz HR tick, which
+ // this codebase keeps parent views clear of on purpose. `getStrapAlarm` no-ops
+ // and logs when nothing is connected, so the worst case is one ignored write.
+ Divider().overlay(StrandPalette.hairline)
+ Button {
+ model.ble.getStrapAlarm()
+ } label: {
+ Text("Check what the strap has stored")
+ }
+ .buttonStyle(NoopButtonStyle(.secondary, fullWidth: true))
+ Text("The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@@ -345,6 +366,9 @@ struct SmartAlarmView: View {
if !on {
for weekday in 1...7 { WindDownNudge.setWakeOverride(weekday: weekday, minutes: nil) }
overrides = [:]
+ // #1864: re-arm the strap alarm + backup notification so they drop the per-day
+ // times and revert to the single default, matching the wind-down nudge's revert.
+ model.applySmartAlarm()
}
}
}
@@ -375,6 +399,9 @@ struct SmartAlarmView: View {
Button {
WindDownNudge.setWakeOverride(weekday: weekday, minutes: nil)
overrides[weekday] = nil
+ // #1864: re-arm so clearing an override reverts that day's wake to the default time
+ // on the strap alarm + backup notification too, not just the wind-down nudge.
+ model.applySmartAlarm()
} label: {
Image(systemName: "arrow.uturn.backward")
.font(.system(size: 12, weight: .semibold))
@@ -407,6 +434,10 @@ struct SmartAlarmView: View {
let m = (c.hour ?? 7) * 60 + (c.minute ?? 0)
WindDownNudge.setWakeOverride(weekday: weekday, minutes: m)
overrides[weekday] = m
+ // #1864: re-arm the strap alarm + backup notification so the new per-day time takes
+ // effect immediately, not just the wind-down reminder. Without this the override moved
+ // only the evening nudge and left the wake on the default time.
+ model.applySmartAlarm()
}
)
}
diff --git a/Strand/Screens/StagesCard.swift b/Strand/Screens/StagesCard.swift
index 8c50f77a91..f22b284cbb 100644
--- a/Strand/Screens/StagesCard.swift
+++ b/Strand/Screens/StagesCard.swift
@@ -123,6 +123,12 @@ struct StageDetailView: View {
if stageStagingIsSparse(night) {
stageIncompleteNote
}
+ // #1716 — a device-provided hypnogram whose records never all arrived leaves a HOLE in the
+ // timeline while the session still spans the whole night, so a night we saw a fraction of
+ // renders as a complete one. Say which fraction, exactly as the Sleep tab does.
+ if let coverage = stageCoverage(night), coverage < HypnogramCoverage.minCoverage {
+ stagePartialNote(coverage)
+ }
// For an Oura-provided night, say plainly that this split is the ring's RAW on-device
// classification — so the larger Awake / smaller Deep+REM here isn't misread as the polished
// numbers the Oura app shows for the same night (the app post-processes the same stream).
@@ -246,6 +252,16 @@ struct StageDetailView: View {
night.sourceBlocks.contains { $0.stagingSparse == true }
}
+ /// How much of this night's window its stage timeline actually accounts for, or nil when coverage is not
+ /// a measurable question for the payloads it was built from (#1716). Same shared group accumulation as
+ /// `SleepView.stageCoverage(_:)` — this host renders the same night and must not reach a different
+ /// verdict about it. Mirror in Kotlin.
+ private func stageCoverage(_ night: Night) -> Double? {
+ let group = SleepView.mainNightGroup(night.sourceBlocks,
+ habitualMidsleepSec: night.habitualMidsleepSec)
+ return HypnogramCoverage.groupFraction(group.isEmpty ? night.sourceBlocks : group)
+ }
+
/// The H9 low-confidence note shown beneath the stage breakdown — a warning-tinted badge plus a
/// one-line honest explanation. No faked stages, no tanked score; just a clear "treat this split with
/// care" so a user doesn't read a likely staging miss as a real deep/REM drought. (#H9)
@@ -278,6 +294,22 @@ struct StageDetailView: View {
.accessibilityElement(children: .combine)
}
+ /// The PARTIAL-TIMELINE caveat (#1716) — twin of `SleepView.stagePartialNote(_:)`, same copy and same
+ /// floored percentage. Says that part of the night is MISSING, which is a different claim from the two
+ /// notes above (a doubted split, and a night staged on thin motion). Changes no number.
+ private func stagePartialNote(_ coverage: Double) -> some View {
+ let pct = Int((coverage * 100).rounded(.down))
+ return HStack(alignment: .top, spacing: 8) {
+ SourceBadge("Partly recorded", tint: StrandPalette.statusWarning)
+ Text("Only \(pct)% of this night's window has stage data. The stage totals cover only that part of the night.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.horizontal, 2)
+ .accessibilityElement(children: .combine)
+ }
+
/// Honest caveat for an Oura-provided night: the stage split shown here is the ring's RAW on-device
/// SleepNet classification, read straight off the BLE phase stream — NOT the adjusted stages the Oura
/// app displays. The app post-processes the same night, so its Deep/REM run higher and its Awake lower;
@@ -421,9 +453,9 @@ struct StageDetailView: View {
}
/// Clock labels for the timeline axis; "jmm" respects the device 12/24-hour setting.
- private static let stageAxisFormatter: DateFormatter = {
- let f = DateFormatter(); f.locale = AppLanguage.activeLocale; f.setLocalizedDateFormatFromTemplate("jmm"); return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var stageAxisFormatter: DateFormatter { AppClock.hourMinuteFormatter() }
/// The WHOOP sleep-stages chart: a stack of four per-stage timeline rows (AWAKE · LIGHT ·
/// DEEP · REM, WHOOP's order) over a shared onset→wake time axis. Each row is independently
diff --git a/Strand/Screens/StressView.swift b/Strand/Screens/StressView.swift
index 88413ec444..803abbbf41 100644
--- a/Strand/Screens/StressView.swift
+++ b/Strand/Screens/StressView.swift
@@ -113,13 +113,11 @@ struct StressView: View {
freqHRV = nil
return
}
- let rr = (try? await repo.storeHandle()?.rrIntervals(
- deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? []
+ let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000)
// Wrist accelerometer for the motion gate: an ambulatory hour is EXERTION, not stress, so it
// is masked rather than scored (DaytimeStress). Same store read as R-R; empty on hardware or
// imports with no gravity, which is exactly the "no masking, prior behaviour" degradation.
- let gravity = (try? await repo.storeHandle()?.gravitySamples(
- deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? []
+ let gravity = await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000)
// Score today's hours against the PERSONAL cross-day daytime baseline ONLY when the user has
// opted in (Settings → Experimental) AND enough worn history exists (Oura-style
@@ -173,8 +171,7 @@ struct StressView: View {
let dayTz = TimeZone.current.secondsFromGMT(for: dayStart)
let dayHR = await repo.hrSamples(from: from, to: to, limit: 200_000)
guard !dayHR.isEmpty else { continue } // unworn day — no floor to learn, skip the R-R read
- let dayRR = (try? await repo.storeHandle()?.rrIntervals(
- deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? []
+ let dayRR = await repo.rrIntervals(from: from, to: to, limit: 200_000)
days.append(.init(hr: dayHR, rr: dayRR, tzOffsetSeconds: dayTz))
}
return DaytimeStress.scoringMode(history: days)
diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift
index ec5a195605..8699f38446 100644
--- a/Strand/Screens/TestCentreView.swift
+++ b/Strand/Screens/TestCentreView.swift
@@ -4,6 +4,7 @@ import StrandAnalytics
import StrandImport
import PolarProtocol
import WhoopStore
+import WhoopProtocol
/// Settings -> Test Centre. The single home for every diagnostic, log and test control (spec section 7).
///
@@ -29,6 +30,11 @@ struct TestCentreView: View {
@State private var infoMessage = ""
@State private var showInfo = false
+ // #1853: skin-temp absolute backfill (on-demand, diagnostic-first). Runs the walker that fills
+ // `skinTempC` for nights outside the 21-night rescore window. In-progress flag + last result line.
+ @State private var skinTempBackfillRunning = false
+ @State private var skinTempBackfillStatus: String?
+
// Section 3: scheduled daily auto-export, the same ScheduledDebugExport store the Settings card uses.
@State private var debugExportOn = ScheduledDebugExport.isEnabled
@State private var debugExportMinutes = ScheduledDebugExport.timeMinutes
@@ -36,6 +42,14 @@ struct TestCentreView: View {
@State private var debugExportKeep = ScheduledDebugExport.keepCount
@State private var showClearExportsConfirm = false
+ // WHOOP 5/MG developer controls. These use the same persisted keys as the former Settings card;
+ // moving the UI does not reset an opt-in or lose the ability to undo a persistent strap write.
+ @AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false
+ @AppStorage(PuffinFrameRecorder.enabledKey) private var puffinCapture = false
+ @AppStorage(PuffinExperiment.deepDataKey) private var deepDataEnabled = false
+ @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false
+ @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false
+
/// The strap model the user last picked, the same key SettingsView's showFiveMGControls gate reads.
@AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = WhoopModel.whoop4.rawValue
@@ -77,6 +91,8 @@ struct TestCentreView: View {
VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) {
domainModesCard.staggeredAppear(index: 0)
diagnosticToolsCard.staggeredAppear(index: 1)
+ if is5MG { rawDataCollectorCard.staggeredAppear(index: 2) }
+ if is5MG { fiveMGProtocolDiagnosticsCard.staggeredAppear(index: 3) }
exportCard.staggeredAppear(index: 2)
experimentalAlgorithmsCard.staggeredAppear(index: 3)
}
@@ -132,6 +148,100 @@ struct TestCentreView: View {
// MARK: - Section 2: Diagnostic tools (strap log + recalibrate + env dump)
+ @ViewBuilder private var rawDataCollectorCard: some View {
+ NoopCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("5/MG RAW DATA COLLECTOR")
+ .font(StrandFont.overline).tracking(StrandFont.overlineTracking)
+ .foregroundStyle(StrandPalette.textSecondary)
+ Text("Record, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ NavigationLink {
+ RawDataCollectorView()
+ } label: {
+ Label("Open raw-data collector", systemImage: "waveform.path.ecg")
+ }
+ .buttonStyle(NoopButtonStyle(.primary, fullWidth: true))
+ Text(live.connected ? "WHOOP 5/MG connected." : "Connect your WHOOP 5/MG to start a raw-data session.")
+ .font(StrandFont.caption)
+ .foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning)
+ }
+ }
+ }
+
+ @ViewBuilder private var fiveMGProtocolDiagnosticsCard: some View {
+ NoopCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("5/MG PROTOCOL DIAGNOSTICS")
+ .font(StrandFont.overline).tracking(StrandFont.overlineTracking)
+ .foregroundStyle(StrandPalette.textSecondary)
+ Text("Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Toggle("Protocol probes", isOn: $puffinExperiments)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("Sends experimental protocol queries and records the replies in the strap log.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("Broadcast heart rate from the strap", isOn: $broadcastHrEnabled)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ .onChangeCompat(of: broadcastHrEnabled) { model.ble.setBroadcastHr($0) }
+ Text("Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("Legacy R22 feature-flag experiment", isOn: $deepDataEnabled)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ if deepDataEnabled {
+ NoopButton("Send legacy R22 enable sequence", systemImage: "bolt.badge.automatic", kind: .secondary) {
+ model.ble.enableWhoop5DeepData()
+ }
+ .disabled(!live.encryptedBond || !live.worn)
+ }
+ NoopButton("Clear legacy R22 flags on strap", systemImage: "bolt.slash", kind: .secondary) {
+ model.ble.disableWhoop5DeepData()
+ }
+ .disabled(!live.encryptedBond || live.r22DisableReport == BLEManager.deviceConfigProbeWaiting)
+ if let result = live.r22DisableReport {
+ Text(result).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("WHOOP MG ECG raw-data gate", isOn: $ecgRawDataEnabled)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("MG-only protocol instrumentation, not a medical ECG feature.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ if ecgRawDataEnabled {
+ HStack(spacing: NoopMetrics.space3) {
+ NoopButton("Gate on", systemImage: "waveform.path.ecg", kind: .secondary) {
+ model.ble.setEcgRawDataGate(true)
+ }
+ NoopButton("Gate off", systemImage: "arrow.uturn.backward", kind: .secondary) {
+ model.ble.setEcgRawDataGate(false)
+ }
+ }
+ .disabled(!live.encryptedBond || live.whoop5Variant != Whoop5Variant.mg.label)
+ if let result = live.ecgRawDataGate {
+ Text(result.summary).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ }
+ }
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("Passive history/protocol trace", isOn: $puffinCapture)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+
@ViewBuilder private var diagnosticToolsCard: some View {
NoopCard {
VStack(alignment: .leading, spacing: NoopMetrics.space3) {
@@ -172,6 +282,25 @@ struct TestCentreView: View {
Divider().overlay(StrandPalette.hairline)
+ // #1853: skin-temp absolute backfill (on-demand, diagnostic-first). Fills `skinTempC`
+ // for nights outside the 21-night rescore window that never got an absolute. Fill-only:
+ // it can only fill a NULL, never overwrite a measured value or touch the deviation.
+ NoopButton("Backfill skin-temp absolutes", systemImage: "thermometer.medium",
+ kind: .secondary) {
+ runSkinTempBackfill()
+ }
+ .disabled(skinTempBackfillRunning)
+ Text("Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ if let status = skinTempBackfillStatus {
+ Text(status)
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Divider().overlay(StrandPalette.hairline)
+
// Environment dump: the IOSDiagnostics-backed block exportableLogText already carries,
// surfaced as a copyable readout (spec section 3.4).
NoopButton("Copy environment dump", systemImage: "info.circle", kind: .secondary) {
@@ -417,6 +546,37 @@ struct TestCentreView: View {
showInfo = true
}
+ /// #1853: run the skin-temp absolute backfill on demand (diagnostic-first trigger). The walker
+ /// resolves the WHOOP 4.0 window anchor from the current scoring window, pages through the
+ /// candidate nights, and fills each NULL `skinTempC`. The result is logged to the strap log and
+ /// shown under the button so the user sees exactly what filled, declined, and had no raw data.
+ private func runSkinTempBackfill() {
+ guard !skinTempBackfillRunning else { return }
+ skinTempBackfillRunning = true
+ skinTempBackfillStatus = String(localized: "Backfilling…")
+ Task { @MainActor in
+ guard let store = await model.repo.storeHandle() else {
+ skinTempBackfillStatus = String(localized: "No on-device store yet.")
+ skinTempBackfillRunning = false
+ return
+ }
+ let walker = SkinTempBackfillWalker(store: store)
+ let result = await walker.runBackfill()
+ let anchorNote = result.windowAnchorRaw != nil
+ ? "anchor from current window"
+ : "no 4.0 anchor (5/MG or too few worn samples)"
+ let endNote = result.reachedEnd ? "reached end" : "hit page budget"
+ let line = "skin-temp backfill (#1853): filled \(result.filledCount), " +
+ "declined \(result.declinedCount), no raw data \(result.noRawDataCount) " +
+ "(\(anchorNote), \(endNote))"
+ live.append(log: line)
+ skinTempBackfillStatus = line
+ skinTempBackfillRunning = false
+ // Refresh so the newly-filled absolutes show in the charts/explorer.
+ await model.repo.refresh()
+ }
+ }
+
private var debugExportTimeBinding: Binding {
Binding(
get: {
@@ -643,7 +803,8 @@ private struct ConnectionReadoutPanel: View {
// LiveState field FrameRouter writes.
let deviceClock = ConnectionReadout.clockCorrelatedDevice(logLines: live.log)
let rtcWarning = ConnectionReadout.rtcWarning(deviceClockUnix: deviceClock,
- strapNewestUnix: live.strapRange?.newestUnix)
+ strapNewestUnix: live.strapRange?.newestUnix,
+ batteryPct: live.batterySamples.last?.soc)
VStack(alignment: .leading, spacing: 4) {
ReadoutRow(label: String(localized: "Connection uptime"), value: uptime)
ReadoutRow(label: String(localized: "Reconnects this run"), value: String(reconnects))
diff --git a/Strand/Screens/TodayCustomizationMetadata.swift b/Strand/Screens/TodayCustomizationMetadata.swift
index 5f9ca15cc0..8e6d9f6775 100644
--- a/Strand/Screens/TodayCustomizationMetadata.swift
+++ b/Strand/Screens/TodayCustomizationMetadata.swift
@@ -48,6 +48,8 @@ extension KeyMetric {
case .steps: return "figure.walk"
case .weight: return "scalemass"
case .calories: return "flame.fill"
+ // Same glyph the sibling "Your Cards" tile (`DashboardCard.skinTemp`) already uses.
+ case .skinTemp: return "thermometer.medium"
}
}
@@ -59,7 +61,7 @@ extension KeyMetric {
case .restingHr: return StrandPalette.metricRose
case .bloodOxygen, .steps: return StrandPalette.metricCyan
case .respiratory, .weight: return StrandPalette.accent
- case .calories: return StrandPalette.metricAmber
+ case .calories, .skinTemp: return StrandPalette.metricAmber
}
}
}
@@ -76,6 +78,7 @@ extension DashboardCard {
case .skinTemp, .calories: return StrandPalette.metricAmber
case .sleep: return StrandPalette.restColor
case .coupled: return StrandPalette.chargeColor
+ case .coach: return StrandPalette.accent
}
}
}
diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift
index aeb367be35..63cf80530a 100644
--- a/Strand/Screens/TodayView.swift
+++ b/Strand/Screens/TodayView.swift
@@ -65,24 +65,32 @@ private struct HRChartFrameKey: PreferenceKey {
struct ActiveWorkoutIndicatorModel: Equatable {
let sport: String
let startedAt: Date
+ /// The pause state has to be CARRIED, not just consulted: this value type is what the card renders
+ /// from, so without these two fields the indicator cannot subtract the paused time or say it is
+ /// paused, however correct `AppModel` is. That is precisely how it kept counting through #1533.
+ var pausedAt: Date? = nil
+ var pausedDuration: TimeInterval = 0
+
+ var isPaused: Bool { pausedAt != nil }
static func make(from workout: AppModel.ActiveWorkout?) -> ActiveWorkoutIndicatorModel? {
guard let workout else { return nil }
- return ActiveWorkoutIndicatorModel(sport: workout.sport, startedAt: workout.start)
+ return ActiveWorkoutIndicatorModel(sport: workout.sport, startedAt: workout.start,
+ pausedAt: workout.pausedAt,
+ pausedDuration: workout.pausedDuration)
}
- /// Elapsed time since `start`, formatted M:SS up to an hour and H:MM:SS once an hour has passed (so a
+ /// Elapsed ACTIVE time, formatted M:SS up to an hour and H:MM:SS once an hour has passed (so a
/// 90-minute session reads "1:30:00", not "90:00"). Clamped at zero so a clock-skew negative reads 0:00.
/// Pure + injectable `now` for deterministic tests. (StrandFont.bodyNumber already applies tabular figures,
/// so the call site does NOT add `.monospacedDigit()`.)
- static func elapsed(since start: Date, now: Date = Date()) -> String {
- let total = max(0, Int(now.timeIntervalSince(start)))
- let h = total / 3600
- let m = (total % 3600) / 60
- let s = total % 60
- return h > 0
- ? String(format: "%d:%02d:%02d", h, m, s)
- : String(format: "%d:%02d", m, s)
+ ///
+ /// `pausedAt`/`pausedDuration` default to "never paused" so the existing call sites and tests that
+ /// predate pause keep their exact meaning; the math itself lives in `ActiveWorkoutClock`.
+ static func elapsed(since start: Date, pausedAt: Date? = nil, pausedDuration: TimeInterval = 0,
+ now: Date = Date()) -> String {
+ ActiveWorkoutClock.clock(Int(ActiveWorkoutClock.activeElapsed(
+ start: start, pausedAt: pausedAt, pausedDuration: pausedDuration, now: now)))
}
}
@@ -103,12 +111,22 @@ private struct ActiveWorkoutIndicatorCard: View {
.font(StrandFont.overline)
.tracking(StrandFont.overlineTracking)
.foregroundStyle(StrandPalette.metricRose)
+ // A frozen clock alone is ambiguous with a STALLED one, so say which it is. Reuses the
+ // "Paused" string #1533 already localized rather than minting new copy for a tag.
+ if model.isPaused {
+ Text("Paused")
+ .font(StrandFont.overline)
+ .tracking(StrandFont.overlineTracking)
+ .foregroundStyle(StrandPalette.textSecondary)
+ }
Spacer(minLength: NoopMetrics.space2)
// A per-second live clock. The TimelineView re-evaluates ONLY this Text every second, so
// the tick never re-renders the rest of the card (let alone TodayView.body). bodyNumber
// already carries `.monospacedDigit()`, so no extra modifier here.
TimelineView(.periodic(from: .now, by: 1)) { context in
- Text(ActiveWorkoutIndicatorModel.elapsed(since: model.startedAt, now: context.date))
+ Text(ActiveWorkoutIndicatorModel.elapsed(
+ since: model.startedAt, pausedAt: model.pausedAt,
+ pausedDuration: model.pausedDuration, now: context.date))
.font(StrandFont.bodyNumber)
.foregroundStyle(StrandPalette.textPrimary)
}
@@ -169,6 +187,8 @@ struct ActiveWorkoutIndicatorSection: View {
}
struct TodayView: View {
+ @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue
+ private var dayCycleMode: DayCycleMode { DayCycleMode.persisted(dayCycleModeRaw) }
/// Product mark, never natural-language copy. Keeping it out of localization also makes source
/// classification and tint selection stable when the app language changes.
private static let whoopBrandName = "WHOOP"
@@ -209,6 +229,7 @@ struct TodayView: View {
/// `MetricExplorerView`, Liquid Today): the explicit override when set, else derived from the unit
/// system. This screen used to print a bare `%+.1f°` and was the last one ignoring the preference.
@AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = ""
+ @AppStorage(UnitPrefs.skinTempDisplayKey) private var skinTempDisplayRaw = "" // #1846
private var temperatureUnit: TemperatureUnit {
UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw)
}
@@ -274,6 +295,10 @@ struct TodayView: View {
// the deferred set immediately (belt-and-braces alongside the coalesced refreshSeq bump). A bare boolean
// that flips ~twice per offload, so it costs nothing like the per-tick chunk count would.
@State private var liveBackfillingFlag = false
+ // #1164: mirror of `LiveState.historyPendingSync` (strap has banked records newer than our frontier).
+ // Bridged through the same `BackfillFlagBridge` as `liveBackfillingFlag` (no second LiveState observer).
+ // Drives the Today Rest "Pending sync" state so a provisional score isn't shown as final.
+ @State private var liveHistoryPendingSyncFlag = false
// #755: have the history-wide reads ever populated this session? Used so the FIRST load always runs them
// (even mid-offload, so a cold launch during a sync is never a blank dashboard), while later re-loads can
// safely defer them during an active backfill.
@@ -282,6 +307,14 @@ struct TodayView: View {
// 14-day sparkline series, keyed by metric key. Loaded once in .task.
@State private var sparks: [String: [Double]] = [:]
@State private var workouts: [WorkoutRow] = []
+ /// #1694: a tapped Latest-Workouts tile. Wrapped so `.sheet(item:)` drives presentation, mirroring
+ /// WorkoutsView's own detail target — the feed was read-only, so the only route to a session's
+ /// detail was More > Workouts.
+ private struct WorkoutDetailTarget: Identifiable {
+ let row: WorkoutRow
+ let id = UUID()
+ }
+ @State private var workoutDetail: WorkoutDetailTarget?
@State private var appleDays: [AppleDaily] = []
// Design Reset / #582, the pinned "Your cards" values (Stress / Fitness age / Vitality), surfaced
// on Today so the buried Explore features sit on the home screen. Loaded in loadAll; nil hides the row.
@@ -374,6 +407,9 @@ struct TodayView: View {
@State private var showSettings = false
@State private var showLiveSession = false
/// The Updates inbox sheet (opened by the header bell). Shared across both platforms.
+ /// #1862: the optional Coach launcher sheet, opened from the default-OFF Coach dashboard card.
+ /// Presentation state only — nothing is requested from a provider by opening it.
+ @State private var showCoachLauncher = false
@State private var showUpdatesInbox = false
/// The NEWEST day-key (max yyyy-MM-dd in `repo.days`) announced to the inbox. Persisted (not @State)
@@ -564,10 +600,43 @@ struct TodayView: View {
return Repository.lastSpo2Day(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey)
}
- /// PER-FIELD skin-temperature-deviation carry — twin of `lastSpo2Day`; mirrors the Android `lastSkinTempRow`.
- private var lastSkinTempDay: DailyMetric? {
+ /// PER-FIELD HRV carry — twin of `lastSpo2Day` for a field `lastVitalsDay`'s OR predicate checks but can
+ /// still resolve nil on: it picks the freshest row with ANY vital, so a respiratory-only row blanks HRV
+ /// (#1842). Mirrors the Android `lastHrvRow`.
+ private var lastHrvDay: DailyMetric? {
+ guard selectedDayOffset == 0 else { return nil }
+ return Repository.lastHrvDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey)
+ }
+
+ /// PER-FIELD resting-HR carry — twin of `lastHrvDay`. Mirrors the Android `lastRestingHrRow`.
+ private var lastRestingHrDay: DailyMetric? {
guard selectedDayOffset == 0 else { return nil }
- return Repository.lastSkinTempDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey)
+ return Repository.lastRestingHrDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey)
+ }
+
+ /// The carry for the surfaces that LEAD WITH THE ABSOLUTE (#1844): the freshest strictly-prior row
+ /// holding EITHER skin-temp number, so a calibrating night — real temperature, no deviation yet — is
+ /// found instead of being skipped for an older deviation. Mirrors the Android `lastSkinTempReadingRow`.
+ private var lastSkinTempReadingDay: DailyMetric? {
+ guard selectedDayOffset == 0 else { return nil }
+ return Repository.lastSkinTempReadingDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey)
+ }
+
+ /// The skin-temp reading a Today surface leads with: today's row if it has either number, else the
+ /// carry. Both numbers always come off the SAME row, so an absolute is never paired with another
+ /// night's deviation.
+ private var skinTempLeadReading: SkinTempDisplay.Reading? {
+ let row = [displayDay, lastVitalsDay, lastSkinTempReadingDay]
+ .compactMap { $0 }
+ .first { $0.skinTempC != nil || $0.skinTempDevC != nil }
+ return SkinTempDisplay.leadReading(absC: row?.skinTempC, devC: row?.skinTempDevC,
+ prefer: skinTempPreferred)
+ }
+
+ /// The user's Settings choice (#1846), resolved from the stored raw. Absent/unrecognised reads as
+ /// `.absolute`, so an install that never opens Settings behaves exactly as before the setting existed.
+ private var skinTempPreferred: SkinTempDisplay.Kind {
+ SkinTempDisplay.Kind(rawValue: skinTempDisplayRaw) ?? .absolute
}
/// PER-FIELD respiratory carry, and the only one of these that is STALENESS-BOUNDED
@@ -664,6 +733,26 @@ struct TodayView: View {
return lastValue
}
+ /// #1164 — should today's Rest show "Pending sync" instead of a provisional number? When the strap has
+ /// banked records not yet offloaded, the Rest score is computed from partial data and will change once
+ /// the full night lands and `analyzeRecent` re-scores it. Surfacing it as "Pending sync" rather than a
+ /// confident number that then moves reads honestly instead of as a bug.
+ ///
+ /// Two honest signals, either of which means more data is expected:
+ /// - `backfilling`: an offload is actively running right now (data is draining).
+ /// - `historyPendingSync`: the strap reports banked records newer than our local frontier (the strap
+ /// has data we haven't ingested yet, even when no offload is running — e.g. right after connect,
+ /// before the first offload starts).
+ ///
+ /// Only applies to TODAY (a past day's score is final — no more data is coming for it) and only when a
+ /// Rest score EXISTS (pending suppresses a provisional number; it does not fabricate one when there is
+ /// none). Pure + unit-testable. Mirror EXACTLY in Kotlin.
+ static func restPendingSync(restScore: Double?, backfilling: Bool,
+ historyPendingSync: Bool, isTodaySelected: Bool) -> Bool {
+ guard isTodaySelected, restScore != nil else { return false }
+ return backfilling || historyPendingSync
+ }
+
/// The carried recovery caption stamp, keyed on that scored day's own date and its recency. Within the
/// freshness cap it reads "Last night · "; once the carried day is older than the cap (#779) it
/// reads "Latest sleep · " so a weeks-old import is never surfaced as "Last night". Shared by every
@@ -1043,6 +1132,16 @@ struct TodayView: View {
stale > Baselines.staleDays {
return "No new nights from your strap for \(stale) days. Check it's connected and saving data."
}
+ // #612 covers a TOTAL drought (nothing valid for staleDays). The common shape is the other one:
+ // nights arriving, most of them empty — five days in with three HRV-less nights sits at "2 of 4"
+ // with no reason given, which reads as a stuck counter. Name the missing nights so the wearer has
+ // something to act on instead of something to wait for.
+ let cov = Baselines.recentHrvCoverage(dayKeys: repo.days.map(\.day),
+ nightlyHrv: repo.days.map(\.avgHrv),
+ today: Repository.logicalDayKey(Date()))
+ if cov.missing > 0, cov.observed > 0 {
+ return "Learning your baseline, \(n) of \(Baselines.minNightsSeed) nights. \(cov.missing) of the last \(cov.observed) nights recorded no HRV. Check the strap is worn overnight and syncing."
+ }
return "Learning your baseline, \(n) of \(Baselines.minNightsSeed) nights."
}
@@ -1415,11 +1514,13 @@ struct TodayView: View {
// zero-size leaf in `.background` (no layout impact) that owns the observation and pushes only
// the boolean EDGE up. loadAll reads the flag to defer the heavy history-wide reads during an
// active offload; the off→false edge below re-runs them as a safety net to the coalesced refresh.
- .background(BackfillFlagBridge(flag: $liveBackfillingFlag))
+ .background(BackfillFlagBridge(flag: $liveBackfillingFlag,
+ pendingSyncFlag: $liveHistoryPendingSyncFlag))
}
// Reload when the data refreshes OR the selected day changes, the HR trend and Rest score are
// day-scoped, so navigating must re-fetch them for the newly selected window.
- .task(id: TodayLoadKey(seq: repo.refreshSeq, offset: selectedDayOffset)) { await loadAll() }
+ .task(id: TodayLoadKey(seq: repo.refreshSeq, offset: selectedDayOffset,
+ dayCycleMode: dayCycleModeRaw)) { await loadAll() }
// #989: hydration writes don't bump refreshSeq, so the card needs its own triggers, a logged /
// edited / deleted drink (hydrationSeq) and the Settings feature toggle both re-read just the two
// hydration fields. Cheap (one metricSeries row), never re-runs the heavy loads.
@@ -1468,6 +1569,9 @@ struct TodayView: View {
ScoringGuideView(onClose: { showGuideTop = false })
}
// The Updates inbox (the header bell). Both platforms.
+ .sheet(isPresented: $showCoachLauncher) {
+ CoachLauncherSheet()
+ }
.sheet(isPresented: $showUpdatesInbox) {
UpdatesInboxView(onClose: { showUpdatesInbox = false })
}
@@ -1493,6 +1597,20 @@ struct TodayView: View {
hostedCardsRaw: $hostedCardsRaw
)
}
+ // #1694: the same read-only WorkoutDetailView the Workouts list opens. Nothing here can edit or
+ // delete, so a tap from Today carries no risk that list does not already carry. Rides its own
+ // NavigationStack because these shared screens are not in a per-screen one — mirrors WorkoutsView.
+ .sheet(item: $workoutDetail) { target in
+ NavigationStack {
+ WorkoutDetailView(row: target.row)
+ .environmentObject(repo)
+ }
+ #if os(iOS)
+ .noopSheetPresentation(largeFirst: true)
+ #else
+ .frame(width: 620, height: 720)
+ #endif
+ }
#if os(iOS)
.fullScreenCover(isPresented: $showLiveSession) {
LiveSessionView(onClose: { showLiveSession = false })
@@ -2495,6 +2613,11 @@ struct TodayView: View {
// coupled day screen. An empty value renders just the icon + title + subtitle + chevron.
pinnedCardRow(icon: card.icon, tint: tint, title: card.title, subtitle: card.subtitle,
value: dashboardValue(card), route: .coupled)
+ case .coach:
+ // #1862: a SHEET, not a push — Coach is a thing you dip into and dismiss, and pushing it
+ // would take you off Today, which is the discoverability problem this card exists to solve.
+ pinnedCardActionRow(icon: card.icon, tint: tint, title: card.title, subtitle: card.subtitle,
+ value: dashboardValue(card)) { showCoachLauncher = true }
}
}
@@ -2516,6 +2639,7 @@ struct TodayView: View {
case .calories: return StrandPalette.metricAmber
case .hydration: return StrandPalette.metricCyan
case .coupled: return StrandPalette.chargeColor
+ case .coach: return StrandPalette.accent
}
}
@@ -2534,12 +2658,18 @@ struct TodayView: View {
#if DEBUG
if let f = DemoDayHarness.active { return withUnit("\(f.hrvMs)") }
#endif
- return withUnit(d?.avgHrv.map { "\(Int($0.rounded()))" } ?? "—")
+ // PER-FIELD carry: today → the freshest prior row that actually HAS an HRV (#1842). Was
+ // today-only, so this card blanked to "—" every rollover while the Key Metrics tile — which
+ // has carried via `carriedVital(perField:)` all along — showed a number on the same screen.
+ // Not the whole-row `lastVitalsDay`: its OR predicate resolves nil HRV on a respiratory-only
+ // row. Mirrors the Android dashboardCardValue.
+ return withUnit((d?.avgHrv ?? lastHrvDay?.avgHrv).map { "\(Int($0.rounded()))" } ?? "—")
case .restingHr:
#if DEBUG
if let f = DemoDayHarness.active { return withUnit("\(f.rhrBpm)") }
#endif
- return withUnit(d?.restingHr.map { "\($0)" } ?? "—")
+ // PER-FIELD carry — twin of `.hrv` above (#1842).
+ return withUnit((d?.restingHr ?? lastRestingHrDay?.restingHr).map { "\($0)" } ?? "—")
case .respiratory:
// PER-FIELD carry: today → the STALENESS-BOUNDED prior night (`lastRespDay`). Recovery-
// independent, so a night with real R-R but a null recovery still carries.
@@ -2578,9 +2708,9 @@ struct TodayView: View {
// deviation, and converts a DELTA by the scale factor alone rather than the absolute formula.
// The card's own unit is deliberately empty — the value carries "°C" / "Δ°F" itself.
// Same per-field carry as Blood Oxygen; both are sparse enough that an old reading is honest.
- return Self.skinTempCardValue(
- d?.skinTempDevC ?? lastVitalsDay?.skinTempDevC ?? lastSkinTempDay?.skinTempDevC,
- fahrenheit: temperatureUnit == .fahrenheit)
+ // #1844: lead with the night's measured ABSOLUTE when it has one (the #1665 rule, applied
+ // here too) — a deviation with no anchor cannot be read. Deviation-only nights are unchanged.
+ return Self.skinTempCardValue(reading: skinTempLeadReading, fahrenheit: temperatureUnit == .fahrenheit)
case .sleep:
return sleepValue(d)
case .steps:
@@ -2618,6 +2748,10 @@ struct TodayView: View {
// A tap-through row with no metric value of its own, the row shows just the chevron. Returning
// an empty string (not "—") renders no number and leaves it un-dimmed (it isn't a missing value).
return ""
+ case .coach:
+ // #1862: likewise a launcher row. Empty rather than "—" for the same reason — there is no
+ // missing measurement here, there is no measurement at all.
+ return ""
}
}
@@ -2629,37 +2763,56 @@ struct TodayView: View {
private func pinnedCardRow(icon: String, tint: Color, title: String, subtitle: String,
value: String, route: TabRoute) -> some View {
NavigationLink(value: route) {
- HStack(spacing: 12) {
- RoundedRectangle(cornerRadius: 9, style: .continuous)
- .fill(tint.opacity(0.14))
- .frame(width: 34, height: 34)
- .overlay(Image(systemName: icon).font(.system(size: 15, weight: .semibold)).foregroundStyle(tint))
- VStack(alignment: .leading, spacing: 2) {
- Text(title.uppercased())
- .font(StrandFont.overline)
- .tracking(StrandFont.overlineTracking)
- .foregroundStyle(StrandPalette.textPrimary)
- .lineLimit(1)
- Text(subtitle)
- .font(StrandFont.footnote)
- .foregroundStyle(StrandPalette.textTertiary)
- .lineLimit(1)
- }
- Spacer(minLength: 8)
- // A real number reads white; a placeholder (, / Calibrating) reads dimmed so it doesn't
- // masquerade as a value.
- let isPlaceholder = (value == "—" || value == Self.calibratingPlaceholder)
- Text(value).font(StrandFont.rounded(18, weight: .semibold))
- .foregroundStyle(isPlaceholder ? StrandPalette.textTertiary : StrandPalette.textPrimary)
- Image(systemName: "chevron.right").font(.system(size: 13, weight: .semibold))
+ pinnedCardRowBody(icon: icon, tint: tint, title: title, subtitle: subtitle, value: value)
+ }
+ .buttonStyle(.plain)
+ }
+
+ /// The same row, but it runs `action` instead of pushing a route (#1862).
+ ///
+ /// Coach is the one dashboard card that opens a SHEET rather than a screen, so it cannot ride
+ /// `NavigationLink`. Both wrappers render `pinnedCardRowBody`, so the two kinds of row cannot drift
+ /// apart visually — which duplicating the HStack for one caller would have guaranteed eventually.
+ private func pinnedCardActionRow(icon: String, tint: Color, title: String, subtitle: String,
+ value: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ pinnedCardRowBody(icon: icon, tint: tint, title: title, subtitle: subtitle, value: value)
+ }
+ .buttonStyle(.plain)
+ }
+
+ @ViewBuilder
+ private func pinnedCardRowBody(icon: String, tint: Color, title: String, subtitle: String,
+ value: String) -> some View {
+ HStack(spacing: 12) {
+ RoundedRectangle(cornerRadius: 9, style: .continuous)
+ .fill(tint.opacity(0.14))
+ .frame(width: 34, height: 34)
+ .overlay(Image(systemName: icon).font(.system(size: 15, weight: .semibold)).foregroundStyle(tint))
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title.uppercased())
+ .font(StrandFont.overline)
+ .tracking(StrandFont.overlineTracking)
+ .foregroundStyle(StrandPalette.textPrimary)
+ .lineLimit(1)
+ Text(subtitle)
+ .font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
+ .lineLimit(1)
}
- .padding(.horizontal, 13).padding(.vertical, 11)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius))
- .contentShape(Rectangle())
+ Spacer(minLength: 8)
+ // A real number reads white; a placeholder (, / Calibrating) reads dimmed so it doesn't
+ // masquerade as a value.
+ let isPlaceholder = (value == "—" || value == Self.calibratingPlaceholder)
+ Text(value).font(StrandFont.rounded(18, weight: .semibold))
+ .foregroundStyle(isPlaceholder ? StrandPalette.textTertiary : StrandPalette.textPrimary)
+ Image(systemName: "chevron.right").font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(StrandPalette.textTertiary)
}
- .buttonStyle(.plain)
+ .padding(.horizontal, 13).padding(.vertical, 11)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(FrostedCardSurface(cornerRadius: NoopMetrics.cardRadius))
+ .contentShape(Rectangle())
}
// MARK: Component 2, explained score note (calibrating / carried / needs-strap)
@@ -2753,16 +2906,26 @@ struct TodayView: View {
// falls back to the last night that recorded THAT vital (`lastVitalsDay`, recovery-INDEPENDENT — a
// night with real HRV/RHR but a null recovery is a valid source, which the old `lastScoredRecoveryDay`
// row-swap skipped). Today's own value always wins the instant it lands.
+ // ...and PER FIELD means per field: `lastVitalsDay`'s predicate is an OR across the three, so it
+ // resolves the freshest row with ANY of them and blanks a vital that row happens to lack (#1842).
+ let hd = lastHrvDay
+ let rd = lastRestingHrDay
let vd = lastVitalsDay
- let hrv = d?.avgHrv ?? vd?.avgHrv
- let rhr = d?.restingHr ?? vd?.restingHr
+ let hrv = d?.avgHrv ?? hd?.avgHrv
+ let rhr = d?.restingHr ?? rd?.restingHr
let resp = d?.respRateBpm ?? vd?.respRateBpm
// The provenance row a shown vital fell back to (nil when every shown vital is today's own): stamps
// that row's own date, so the footnote can't claim "Last night" for a value that IS today's.
- let carriedFromHrv = d?.avgHrv == nil && vd?.avgHrv != nil
- let carriedFromRhr = d?.restingHr == nil && vd?.restingHr != nil
- let carriedFromResp = d?.respRateBpm == nil && vd?.respRateBpm != nil
- let provenance: DailyMetric? = (carriedFromHrv || carriedFromRhr || carriedFromResp) ? vd : nil
+ // Each vital can now carry from a DIFFERENT row, so the one card-level footnote stamps the OLDEST
+ // row any SHOWN carried vital came from. Erring old is the only safe direction for a caption whose
+ // job is to stop a stale read passing as today's, and it keeps `carriedCaption`'s "Latest sleep"
+ // relabel (#779) firing on the value that actually is weeks old. A row is only a source if it
+ // SUPPLIED the value — `vd` can hold a nil respiratory, which carries nothing and stamps nothing.
+ let carriedFromHrv: DailyMetric? = (d?.avgHrv == nil && hd?.avgHrv != nil) ? hd : nil
+ let carriedFromRhr: DailyMetric? = (d?.restingHr == nil && rd?.restingHr != nil) ? rd : nil
+ let carriedFromResp: DailyMetric? = (d?.respRateBpm == nil && vd?.respRateBpm != nil) ? vd : nil
+ let sources: [DailyMetric] = [carriedFromHrv, carriedFromRhr, carriedFromResp].compactMap { $0 }
+ let provenance: DailyMetric? = sources.min(by: { $0.day < $1.day })
NoopCard(tint: StrandPalette.chargeColor) {
VStack(spacing: 0) {
// DEBUG promo harness: pin HRV / Resting HR to the active frame's values. No-op otherwise.
@@ -2775,11 +2938,11 @@ struct TodayView: View {
#endif
metricRow(icon: "waveform.path.ecg", label: "HRV",
value: demoHrv ?? (hrv.map { "\(Int($0.rounded()))" } ?? "—"), unit: "ms",
- tint: StrandPalette.metricCyan)
+ tint: StrandPalette.metricCyan, route: .metric("hrv"))
Divider().overlay(StrandPalette.hairline)
metricRow(icon: "heart.fill", label: "Resting HR",
value: demoRhr ?? (rhr.map { "\($0)" } ?? "—"), unit: "bpm",
- tint: StrandPalette.metricRose)
+ tint: StrandPalette.metricRose, route: .metric("rhr"))
Divider().overlay(StrandPalette.hairline)
metricRow(icon: "lungs.fill", label: "Respiratory",
// Today's own respiratory, else the carried night's; a non-carrying today keeps the
@@ -2787,7 +2950,7 @@ struct TodayView: View {
value: resp.map { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) }
?? (vd == nil ? latestString("resp_rate", decimals: 1) : "—"),
unit: "rpm",
- tint: StrandPalette.accent)
+ tint: StrandPalette.accent, route: .metric("resp_rate"))
// ONE provenance footnote when a shown vital is a carried prior-day read (not today's),
// stamped with THAT row's date via the shared caption (which relabels a weeks-old carry to
// "Latest sleep", #779), so a prior read is never silently passed off as today.
@@ -2813,7 +2976,26 @@ struct TodayView: View {
/// One README "metric row": a metric-hue line icon, a secondary label, and a right-aligned bold
/// value with a small unit. Rows are divided by a hairline. Shared by the Today vitals card.
@ViewBuilder
- private func metricRow(icon: String, label: LocalizedStringKey, value: String, unit: String, tint: Color) -> some View {
+ /// A vitals row, optionally pushing its own metric trend (#706/#684).
+ ///
+ /// `route: nil` renders exactly what shipped before - no link, no chevron - so the three other callers
+ /// are untouched and a row that goes nowhere never claims otherwise. `LiquidPressStyle` is not
+ /// decoration: a bare `NavigationLink` applies the default link chrome and would tint the whole row,
+ /// which is why `cardLink` carries it too.
+ private func metricRow(icon: String, label: LocalizedStringKey, value: String, unit: String,
+ tint: Color, route: TabRoute? = nil) -> some View {
+ Group {
+ if let route {
+ NavigationLink(value: route) { metricRowBody(icon, label, value, unit, tint, linked: true) }
+ .buttonStyle(LiquidPressStyle())
+ } else {
+ metricRowBody(icon, label, value, unit, tint, linked: false)
+ }
+ }
+ }
+
+ private func metricRowBody(_ icon: String, _ label: LocalizedStringKey, _ value: String,
+ _ unit: String, _ tint: Color, linked: Bool) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
@@ -2837,6 +3019,12 @@ struct TodayView: View {
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
}
+ // Only when the row goes somewhere: a row that cannot navigate must not imply it can.
+ if linked {
+ Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(StrandPalette.textTertiary)
+ .accessibilityHidden(true)
+ }
}
.padding(.vertical, 13)
.accessibilityElement(children: .combine)
@@ -3149,7 +3337,14 @@ struct TodayView: View {
/// Rest (sleep composite 0–100) hero ring.
@ViewBuilder
private func restRing(diameter: CGFloat) -> some View {
- if let s = restScore {
+ // #1164: when the strap has banked records not yet offloaded, today's Rest is provisional — it
+ // will change once the full night lands and `analyzeRecent` re-scores it. Show "Pending sync"
+ // instead of a confident number that then moves. Past days are final (no more data coming).
+ if Self.restPendingSync(restScore: restScore, backfilling: liveBackfillingFlag,
+ historyPendingSync: liveHistoryPendingSyncFlag,
+ isTodaySelected: selectedDayOffset == 0) {
+ emptyHeroRing(diameter: diameter) { ringPendingSync() }
+ } else if let s = restScore {
GlowRing(fraction: s / 100, value: s, format: { "\(Int($0.rounded()))" },
color: StrandPalette.restColor, diameter: diameter, lineWidth: diameter * 0.10)
} else if displayDay?.recovery != nil {
@@ -3177,6 +3372,20 @@ struct TodayView: View {
}
}
+ /// #1164: the Rest ring's overlay when today's score is provisional because the strap still has
+ /// banked records not yet offloaded. Shows "Pending sync" instead of a number that will change once
+ /// the full night lands and `analyzeRecent` re-scores it. Mirrors Android's RingPendingSync.
+ @ViewBuilder
+ private func ringPendingSync() -> some View {
+ VStack(spacing: 3) {
+ Text("Pending sync").font(StrandFont.headline).foregroundStyle(StrandPalette.textTertiary)
+ .lineLimit(1).minimumScaleFactor(0.7).fixedSize()
+ Text("strap history still offloading").font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textSecondary)
+ .lineLimit(1).minimumScaleFactor(0.6).fixedSize()
+ }
+ }
+
/// The faint full-circle track with a centred overlay, shown when a score is still calibrating/absent.
@ViewBuilder
private func emptyHeroRing(diameter: CGFloat, @ViewBuilder overlay: () -> Overlay) -> some View {
@@ -3244,8 +3453,14 @@ struct TodayView: View {
// the same lineLimit/scaleFactor guard so it never wraps, then its "N of 4" subtitle below.
Text("Calibrating").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary)
.lineLimit(1).minimumScaleFactor(0.7).fixedSize()
- Text("\(n) of \(Baselines.minNightsSeed)").font(StrandFont.footnote).foregroundStyle(StrandPalette.textSecondary)
- .lineLimit(1)
+ // #1816's lesson on a second tile: a bare "2 of 4" under "Calibrating" is read as DAYS,
+ // and a wearer five days in reports it stuck. It counts NIGHTS THAT BANKED A USABLE HRV
+ // (`Baselines.update` only advances `nValid` for a non-nil in-range value), so a week of
+ // wear with three R-R-less nights genuinely sits at 2. Naming the unit is the whole fix:
+ // the number is right, the reader's unit was not.
+ Text("\(n) of \(Baselines.minNightsSeed) nights").font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textSecondary)
+ .lineLimit(1).minimumScaleFactor(0.7)
} else {
ringNoData(diameter: diameter)
}
@@ -3641,23 +3856,40 @@ struct TodayView: View {
accessory: { scoreInfoButton(.effort) }
)
case .rest:
- // Unscored TODAY → "building, wear it tonight" instead of a lone ", " caption (#527);
- // a scored day keeps its sleep-duration / efficiency caption.
- StatTile(
- label: "Rest",
- value: restScore.map { "\(Int($0.rounded()))%" } ?? "—",
- // Component 2: a scored day shows its duration/efficiency caption; an unscored TODAY shows
- // the "building" hint; a past day with no Rest falls to the honest "Needs the strap" rather
- // than a bare blank, so the tile always carries a state.
- caption: restScore != nil ? restCaption(d)
- : (buildingHint(.rest) ?? restCaption(d) ?? Self.needsStrapCaption),
- accent: restScore.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary,
- // The Rest composite (0–100) trend, not raw sleep minutes, tracks the score above (#614).
- sparkline: sparks["sleep_performance"],
- sparkColor: StrandPalette.metricPurple,
- // Inline ⓘ in the tile header (not a corner overlay) so it never sits over the value (#495).
- accessory: { scoreInfoButton(.rest) }
- )
+ // #1164: when today's Rest is provisional (strap has banked records not yet offloaded), show
+ // "Pending sync" instead of a number that will change once the full night lands. Past days
+ // are final (no more data coming), so the pending state is today-only.
+ if Self.restPendingSync(restScore: restScore, backfilling: liveBackfillingFlag,
+ historyPendingSync: liveHistoryPendingSyncFlag,
+ isTodaySelected: selectedDayOffset == 0) {
+ StatTile(
+ label: "Rest",
+ value: "—",
+ caption: String(localized: "Pending sync · strap history still offloading"),
+ accent: StrandPalette.textPrimary,
+ sparkline: sparks["sleep_performance"],
+ sparkColor: StrandPalette.metricPurple,
+ accessory: { scoreInfoButton(.rest) }
+ )
+ } else {
+ // Unscored TODAY → "building, wear it tonight" instead of a lone ", " caption (#527);
+ // a scored day keeps its sleep-duration / efficiency caption.
+ StatTile(
+ label: "Rest",
+ value: restScore.map { "\(Int($0.rounded()))%" } ?? "—",
+ // Component 2: a scored day shows its duration/efficiency caption; an unscored TODAY shows
+ // the "building" hint; a past day with no Rest falls to the honest "Needs the strap" rather
+ // than a bare blank, so the tile always carries a state.
+ caption: restScore != nil ? restCaption(d)
+ : (buildingHint(.rest) ?? restCaption(d) ?? Self.needsStrapCaption),
+ accent: restScore.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textPrimary,
+ // The Rest composite (0–100) trend, not raw sleep minutes, tracks the score above (#614).
+ sparkline: sparks["sleep_performance"],
+ sparkColor: StrandPalette.metricPurple,
+ // Inline ⓘ in the tile header (not a corner overlay) so it never sits over the value (#495).
+ accessory: { scoreInfoButton(.rest) }
+ )
+ }
case .hrv:
// Carry the last scored night's HRV at the rollover (#543), today's wins, the carried value
// is stamped "Last night · ", and a never-scored metric still shows ", ".
@@ -3808,27 +4040,64 @@ struct TodayView: View {
sparkline: sparks["active_kcal"],
sparkColor: StrandPalette.metricAmber
)
+ case .skinTemp:
+ // Added 2026-08-24 (queue 11c follow-up): first Key Metrics appearance for Skin Temp — was
+ // already a "Your Cards" tile (`DashboardCard.skinTemp`), never a Key Metrics one. Reuses the
+ // SAME value chain and `skinTempCardValue` formatter the "Your Cards" case above already
+ // uses, so the two tiles can never disagree.
+ let skinTempValue = skinTempLeadReading
+ StatTile(
+ label: "Skin Temp",
+ value: Self.skinTempCardValue(reading: skinTempValue, fahrenheit: temperatureUnit == .fahrenheit),
+ caption: skinTempValue == nil ? Self.needsStrapCaption : "",
+ accent: skinTempValue == nil ? StrandPalette.textPrimary : StrandPalette.metricAmber,
+ sparkline: sparks["skin_temp"],
+ sparkColor: StrandPalette.metricAmber
+ )
}
}
// MARK: (c) LAST WORKOUTS, SAME grid, uniform 104pt workout tiles.
+ /// Android's Today feed contract (`TodayScreen.recentCutoff`): sessions starting on or after the
+ /// start of the day 13 days back — 14 days counting today. Named rather than inlined so the window
+ /// is one thing on this platform too, and so the parity guard has something to point at.
+ static func recentWorkoutsFeed(_ rows: [WorkoutRow], now: Date = Date()) -> [WorkoutRow] {
+ let cal = Calendar.current
+ guard let cutoff = cal.date(byAdding: .day, value: -13, to: cal.startOfDay(for: now)) else { return rows }
+ let cutoffTs = Int(cutoff.timeIntervalSince1970)
+ return rows.filter { $0.startTs >= cutoffTs }
+ }
+
@ViewBuilder
private var workoutsSection: some View {
- if !workouts.isEmpty {
+ // #1702: window HERE, not on `workouts`. That array is shared — it also feeds the Data Sources
+ // Apple-workout count and the HR chart's sport glyphs, both all-time by design — so windowing it
+ // at the source would silently shrink two unrelated numbers on this same screen.
+ let recent = Self.recentWorkoutsFeed(workouts)
+ if !recent.isEmpty {
VStack(alignment: .leading, spacing: NoopMetrics.gap) {
+ // "14 days" describes the window, like Android's today_workouts_14_days. The old
+ // "\(count) total" counted every workout ever recorded while showing at most six.
SectionHeader("Latest Workouts", overline: "Activity",
- trailing: String(localized: "\(workouts.count) total"))
+ trailing: String(localized: "14 days"))
LazyVGrid(columns: grid, alignment: .leading, spacing: NoopMetrics.gap) {
- ForEach(Array(workouts.prefix(6).enumerated()), id: \.offset) { _, w in
- StatTile(
- label: "\(WorkoutSource.displaySport(w.sport))",
- value: workoutDuration(w),
- caption: workoutCaption(w),
- accent: StrandPalette.effortTint(fraction: (w.strain ?? 0) / StrainScorer.maxStrain),
- delta: w.energyKcal.map { "\(Int($0.rounded())) kcal" },
- deltaColor: StrandPalette.metricAmber
- )
+ ForEach(Array(recent.prefix(6).enumerated()), id: \.offset) { _, w in
+ Button {
+ workoutDetail = WorkoutDetailTarget(row: w)
+ } label: {
+ StatTile(
+ label: "\(WorkoutSource.displaySport(w.sport))",
+ value: workoutDuration(w),
+ caption: workoutCaption(w),
+ accent: StrandPalette.effortTint(fraction: (w.strain ?? 0) / StrainScorer.maxStrain),
+ delta: w.energyKcal.map { "\(Int($0.rounded())) kcal" },
+ deltaColor: StrandPalette.metricAmber
+ )
+ }
+ // The Workouts list's own rows use this, not .plain: it is the iOS twin of
+ // Android's liquidPress, so the tile settles inward on press on both platforms.
+ .buttonStyle(LiquidPressStyle())
}
}
}
@@ -4116,13 +4385,35 @@ struct TodayView: View {
/// number, and there is nothing for the user to go and do. `StatTile.caption` is optional, so nil
/// renders NO caption rather than falling back to "today" — which would be its own small lie on a past
/// day being browsed.
- /// Twin of the Kotlin `stepsCalibrationPrompt` guard (#1514).
+ ///
+ /// #1816: when the strap has banked NO motion, the phone-step-days countdown is the wrong message.
+ /// A step estimate is `motion * coefficient`, so with the motion half missing neither the estimate
+ /// nor the fit moves however many days the phone counts — and the countdown that names only the
+ /// phone half sent a field reporter to enter Apple Health steps by hand expecting calibration to
+ /// start, which it cannot. The `stepsHasBankedMotion` flag is persisted by `IntelligenceEngine` on
+ /// every analytics pass, so it tracks a fresh strap's first sync without a per-render query. When
+ /// it is false, the caption says "No motion synced yet" instead — the same wording the calibration
+ /// sheet's no-motion banner uses, so the two surfaces agree. Twin of the Kotlin
+ /// `stepsCalibrationPrompt` guard (#1514).
private var stepsCalibrationCaption: String? {
- guard profile.stepsCalibrationCoefficient <= 0, profile.stepsManualCoefficient <= 0 else {
- return nil
- }
+ Self.stepsCalibrationCaption(coefficient: profile.stepsCalibrationCoefficient,
+ manualCoefficient: profile.stepsManualCoefficient,
+ hasBankedMotion: profile.stepsHasBankedMotion,
+ sampleDays: profile.stepsCalibrationSampleDays)
+ }
+
+ /// #1816: the pure decision behind `stepsCalibrationCaption`, extracted so it can be unit-tested
+ /// without a live view. Returns nil once a coefficient exists (a blank day is just a quiet one,
+ /// not a missing input). Returns "No motion synced yet" when the strap has banked no motion —
+ /// the motion half is the blocker, not the phone half, and the countdown that names only the
+ /// phone half is a lie. Otherwise returns the engine's `needsMoreDays` headline. Twin of the
+ /// Kotlin `stepsCalibrationPrompt` guard.
+ static func stepsCalibrationCaption(coefficient: Double, manualCoefficient: Double,
+ hasBankedMotion: Bool, sampleDays: Int) -> String? {
+ guard coefficient <= 0, manualCoefficient <= 0 else { return nil }
+ if !hasBankedMotion { return String(localized: "No motion synced yet") }
let status = StepsEstimateEngine.CalibrationStatus.needsMoreDays(
- have: profile.stepsCalibrationSampleDays,
+ have: sampleDays,
need: StepsEstimateEngine.minCalibrationDays)
return status.headline
}
@@ -4218,7 +4509,7 @@ struct TodayView: View {
}
let hostedSessions = await repo.allSleepSessions()
let hostedHabitual = await repo.habitualMidsleepSec()
- let hostedMotion = await repo.sessionMotions(starts: hostedSessions.map { $0.startTs })
+ let hostedMotion = await repo.sessionMotions(sessions: hostedSessions)
hostedSleepModel = SleepModel.build(SleepModelInputs(
days: repo.days,
sleeps: repo.sleeps,
@@ -4256,6 +4547,10 @@ struct TodayView: View {
// toggle is OFF (the engine writes nothing) or the owner has no in-band reading. Used as a
// fallback for the Blood Oxygen tile when `spo2Pct` is nil, labelled "strap estimate (unverified)".
async let spo2CandidateSpark = sparkValuesExplore("spo2_candidate", source: "my-whoop", window: 14)
+ // Added 2026-08-24 (queue 11c follow-up) for the new Skin Temp Key Metrics tile. `exploreSeries`
+ // so a BLE-only strap's computed `DailyMetric.skinTempDevC` column backs the trend, same as
+ // `resp_rate` above — the engine writes the column, not a metricSeries point.
+ async let skinTempSpark = sparkValuesExplore("skin_temp", source: "my-whoop", window: 14)
// `resp_rate` via `exploreSeries` so a BLE-only WHOOP 5 user's on-device computed
// `DailyMetric.respRateBpm` backs the trend (the engine writes the column, not a metricSeries
// point). The old `series(… source: "apple-health")` read only Apple Health's metricSeries,
@@ -4273,6 +4568,7 @@ struct TodayView: View {
sparks["rhr"] = await rhrSpark
sparks["spo2"] = await spo2Spark
sparks["spo2_candidate"] = await spo2CandidateSpark
+ sparks["skin_temp"] = await skinTempSpark
sparks["resp_rate"] = await respRateSpark
sparks["steps"] = await stepsAppleSpark
// Steps prefer the strap's own @57 daily total (no metricSeries, it lives on the daily row),
@@ -4479,6 +4775,8 @@ struct TodayView: View {
// Rest series + the two provenance resolves, all day-keyed outputs, none consumes another's
// result, so fire them concurrently and await where first used.
async let restSeriesA = repo.exploreSeries(key: "sleep_performance", source: "my-whoop")
+ async let dayCycleSeriesA = repo.exploreSeries(
+ key: DayCycleIntelligenceIntegration.onsetKey, source: "my-whoop")
async let recoveryResolvedA = repo.resolvedSeries(key: "recovery", source: Repository.whoopSource)
async let restResolvedA = repo.resolvedSeries(key: "sleep_performance", source: Repository.whoopSource)
@@ -4486,6 +4784,7 @@ struct TodayView: View {
// `sleep_performance` (imported-wins), so a Bluetooth-only user sees the on-device Rest
// composite and an importer sees the export's figure, exactly like the Rest detail screen.
let restSeries = await restSeriesA
+ let dayCycleSeries = await dayCycleSeriesA
let restByDay = Dictionary(restSeries.map { ($0.day, $0.value) }, uniquingKeysWith: { _, last in last })
// The Rest TILE's sparkline (#614 follow-up). The tile's number is `restScore` (the Rest composite,
// 0–100) but its mini-graph used to plot raw sleep MINUTES (`sparks["sleep_total_min"]`), so the
@@ -4535,11 +4834,20 @@ struct TodayView: View {
// in the small hours after midnight today still starts at yesterday's midnight rather than
// blanking to an empty new-calendar-day axis (#144).
let dayStart = Calendar.current.startOfDay(for: selectedLogicalDay)
- let windowStart = Int(dayStart.timeIntervalSince1970)
- let windowEnd: Int = selectedDayOffset == 0
+ let calendarStart = Int(dayStart.timeIntervalSince1970)
+ let calendarEnd: Int = selectedDayOffset == 0
? Int(Date().timeIntervalSince1970)
: Int((Calendar.current.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart).timeIntervalSince1970)
- let hrPointsLocal = await repo.hrBuckets(from: windowStart, to: windowEnd, bucketSeconds: 300)
+ let nextDay = Calendar.current.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
+ let nextDayKey = Repository.localDayKey(nextDay)
+ let cycleMarkers = dayCycleMode == .sleepOnset
+ ? await repo.exploreSeries(key: DayCycleIntelligenceIntegration.onsetKey, source: "my-whoop") : []
+ let windowStart = cycleMarkers.last(where: { $0.day == selectedDayKey }).map { Int($0.value) }
+ ?? calendarStart
+ let windowEndExclusive = cycleMarkers.last(where: { $0.day == nextDayKey }).map { Int($0.value) }
+ ?? calendarEnd
+ let windowEndInclusive = max(windowStart, windowEndExclusive - 1)
+ let hrPointsLocal = await repo.hrBuckets(from: windowStart, to: windowEndInclusive, bucketSeconds: 300)
.map { TrendPoint(date: Date(timeIntervalSince1970: TimeInterval($0.ts)), value: $0.bpm) }
hrPoints = hrPointsLocal
@@ -4549,7 +4857,7 @@ struct TodayView: View {
// canonical UNION (like the HR curve / Effort above): a re-added strap banks its live step samples
// under its OWN fresh id, so a read pinned to the canonical "my-whoop" would drop the icon for a
// re-added strap (the #904/#908 family). nil (no classed sample) hides the icon.
- let stepClassLocal = await repo.stepActivityClassLatest(from: windowStart, to: windowEnd)
+ let stepClassLocal = await repo.stepActivityClassLatest(from: windowStart, to: windowEndInclusive)
stepActivityClassToday = stepClassLocal
// #860 item 1: the launch auto-land (#605/#739 "snap to the most recent data day when today is
@@ -4566,7 +4874,18 @@ struct TodayView: View {
// gauge falls back to the stored row (never a fabricated value); a navigated past day clears it.
let liveStrainLocal: Double?
if selectedDayOffset == 0 {
- let todayHr = await repo.hrSamples(from: windowStart, to: windowEnd)
+ let mode = DayCycleMode.persisted(UserDefaults.standard.string(forKey: DayCycleMode.storageKey))
+ let cycleOnset = dayCycleSeries.last(where: { $0.day <= selectedDayKey })
+ .map { Int($0.value.rounded()) }
+ let effortStart = mode == .sleepOnset ? (cycleOnset ?? windowStart) : windowStart
+ // An EXPLICIT limit, not the 8000 default: that default is chart-sized, and this read is
+ // whole-window. `hrSamples` is `ORDER BY ts ASC LIMIT`, so truncation drops the NEWEST rows —
+ // at the ~18k HR rows a real day banks, the default covered roughly the first ten hours and the
+ // live score silently stopped climbing after that. It failed safe (`effectiveEffort` takes the
+ // max, so the stored row simply won) which is why it went unnoticed. 200_000 is what every
+ // other whole-window HR consumer already passes.
+ let todayHr = await repo.hrSamples(from: effortStart, to: windowEndInclusive,
+ limit: 200_000)
let maxHR = profile.age > 0 ? StrainScorer.tanakaHRmax(age: Double(profile.age)) : nil
let restHR = displayDay?.restingHr.map(Double.init) ?? StrainScorer.defaultRestingHR
liveStrainLocal = StrainScorer.strain(todayHr, maxHR: maxHR, restingHR: restHR,
@@ -4578,7 +4897,7 @@ struct TodayView: View {
// Pin the chart axis to the loaded window, today midnight→now, a past day the full 24h, so
// a gap (e.g. a morning the strap wasn't banking) shows as empty space, not a late start.
let newAxis = Date(timeIntervalSince1970: TimeInterval(windowStart))
- ... Date(timeIntervalSince1970: TimeInterval(windowEnd))
+ ... Date(timeIntervalSince1970: TimeInterval(windowEndExclusive))
// #829 - keep the HR zoom VALID across reloads. The window changes on a day step (a whole new day)
// and, on today, each refresh nudges the end to a fresh `now`. A day step clears the zoom so the new
// day opens at full scale; a same-day end-extension keeps the user's zoom but RE-CLAMPS it into the
@@ -4596,7 +4915,7 @@ struct TodayView: View {
// read for a night stored as more than one block (#294). Drives the HR sleep band + the recovery
// marker's wake anchor.
let overlapping = await repo.allSleepSessions(days: selectedDayOffset + 2)
- .filter { $0.endTs > windowStart && $0.startTs < windowEnd }
+ .filter { $0.endTs > windowStart && $0.startTs < windowEndExclusive }
let habitualMidsleepSecLocal = await repo.habitualMidsleepSec()
let sleepTodayLocal = SleepView.mainNightSpan(overlapping, habitualMidsleepSec: habitualMidsleepSecLocal)
.map { span in
@@ -4882,6 +5201,15 @@ struct TodayView: View {
/// The Skin Temp card's value, extracted so the bimodal-column decision can be unit-tested without a
/// live view. Nil (no reading anywhere in the carry chain) reads as an em-dash rather than a number.
+ /// The Skin Temp card's value when the surface LEADS WITH THE ABSOLUTE (#1844) — the row supplies both
+ /// numbers and `SkinTempDisplay.leadReading` picks, so a night that measured a real temperature shows
+ /// one and only a night without falls back to the signed deviation. Nil (neither number anywhere in the
+ /// carry chain) reads as an em-dash. The `Double?` sibling below stays for the deviation-only callers.
+ static func skinTempCardValue(reading: SkinTempDisplay.Reading?, fahrenheit: Bool) -> String {
+ guard let reading else { return "—" }
+ return SkinTempDisplay.formatReading(reading, fahrenheit: fahrenheit)
+ }
+
static func skinTempCardValue(_ value: Double?, fahrenheit: Bool) -> String {
guard let value else { return "—" }
return SkinTempDisplay.format(value, fahrenheit: fahrenheit)
@@ -4953,12 +5281,9 @@ struct TodayView: View {
/// show times, not the day-granularity default ("EEE d MMM"). Also formats the workout-tile caption's
/// time range (#157). The "jmm" skeleton respects the device's 12-/24-hour setting (#337): "7:10 AM"
/// where 12-hour is preferred, "19:10" where 24-hour is, instead of forcing one on everyone.
- static let hrTimeFmt: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.setLocalizedDateFormatFromTemplate("jmm")
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ static var hrTimeFmt: DateFormatter { AppClock.hourMinuteFormatter() }
}
/// `.task(id:)` key combining the data refresh sequence with the selected day so a reload runs on
@@ -4966,6 +5291,7 @@ struct TodayView: View {
private struct TodayLoadKey: Equatable {
let seq: Int
let offset: Int
+ let dayCycleMode: String
}
/// #849: an in-memory snapshot of everything `loadHistoryWide()` computes: the ~40 history-wide reads +
@@ -5162,12 +5488,21 @@ private struct SyncingHistoryNoteIfBackfilling: View {
private struct BackfillFlagBridge: View {
@EnvironmentObject private var live: LiveState
@Binding var flag: Bool
+ /// #1164: optional mirror of `LiveState.historyPendingSync` (strap has banked records newer than our
+ /// frontier). Bridged through the SAME invisible leaf so a second LiveState observer isn't added to
+ /// the view tree (the 1 Hz flood isolation the top-of-type note describes). nil when the caller
+ /// doesn't need it.
+ @Binding var pendingSyncFlag: Bool
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.accessibilityHidden(true)
- .onAppear { if flag != live.backfilling { flag = live.backfilling } }
+ .onAppear {
+ if flag != live.backfilling { flag = live.backfilling }
+ if pendingSyncFlag != live.historyPendingSync { pendingSyncFlag = live.historyPendingSync }
+ }
.onChangeCompat(of: live.backfilling) { now in if flag != now { flag = now } }
+ .onChangeCompat(of: live.historyPendingSync) { now in if pendingSyncFlag != now { pendingSyncFlag = now } }
}
}
diff --git a/Strand/Screens/UpdatesInboxView.swift b/Strand/Screens/UpdatesInboxView.swift
index ef80eb26db..d73f6b5d21 100644
--- a/Strand/Screens/UpdatesInboxView.swift
+++ b/Strand/Screens/UpdatesInboxView.swift
@@ -249,6 +249,8 @@ private struct UpdateRow: View {
case .whatsNew: return "sparkles"
case .reading: return "waveform.path.ecg"
case .strapAlert: return "exclamationmark.triangle"
+ // Distinct from `.whatsNew`'s sparkles on purpose: this is something you have NOT got yet.
+ case .newVersion: return "arrow.down.circle"
}
}
@@ -259,6 +261,7 @@ private struct UpdateRow: View {
case .whatsNew: return StrandPalette.accent
case .reading: return StrandPalette.restColor
case .strapAlert: return StrandPalette.statusWarning
+ case .newVersion: return StrandPalette.accent
}
}
diff --git a/Strand/Screens/V5PillarHosts.swift b/Strand/Screens/V5PillarHosts.swift
index d96312f653..a049b4193e 100644
--- a/Strand/Screens/V5PillarHosts.swift
+++ b/Strand/Screens/V5PillarHosts.swift
@@ -78,23 +78,15 @@ struct RhythmHost: View {
/// Read the most recent banked sleep session, pull its R-R + gravity, split into ~5-minute windows,
/// gate each on stillness + resting rate, and screen it. Descriptive stats only — never a verdict.
private func load() async {
- guard let store = await repo.storeHandle(),
- let lastSleep = (await repo.allSleepSessions(days: 14)).last else { return }
+ guard let lastSleep = (await repo.allSleepSessions(days: 14)).last else { return }
let lo = lastSleep.effectiveStartTs
let hi = lastSleep.endTs
guard hi > lo else { return }
- let rr = (try? await store.rrIntervals(deviceId: repo.deviceId, from: lo, to: hi, limit: 200_000)) ?? []
- // BLE-only users have their night under the computed source; fall back to it when the imported
- // device yields nothing. Gravity MUST be read from the SAME source id as the R-R (#1360): otherwise
- // a fall-back night reads its stillness from the wrong device and every window fails the motion
- // gate, which the empty-state diagnosis below would then misread as "no stillness signal at all".
- let usedFallback = rr.isEmpty
- let sourceId = usedFallback ? repo.deviceId + "-noop" : repo.deviceId
- let rrRows = usedFallback
- ? ((try? await store.rrIntervals(deviceId: sourceId, from: lo, to: hi, limit: 200_000)) ?? [])
- : rr
+ // The selected night can belong to a previous/archived strap. Resolve the same all-WHOOP worn
+ // timeline used by Sleep rather than pinning its raw physiology to whichever strap is active now.
+ let rrRows = await repo.rrIntervals(from: lo, to: hi, limit: 200_000)
guard !rrRows.isEmpty else { return }
- let grav = (try? await store.gravitySamples(deviceId: sourceId, from: lo, to: hi, limit: 200_000)) ?? []
+ let grav = await repo.gravitySamplesUnion(from: lo, to: hi, limit: 200_000)
// Window the night into 5-minute slices; a slice is "still" when its gravity variance is small.
let windowSec = 5 * 60
diff --git a/Strand/Screens/VitalSignsSummary.swift b/Strand/Screens/VitalSignsSummary.swift
index 0cb30a375b..a56dc67a84 100644
--- a/Strand/Screens/VitalSignsSummary.swift
+++ b/Strand/Screens/VitalSignsSummary.swift
@@ -27,6 +27,14 @@ struct BodyVitalReading: Identifiable {
/// strap's own capture is known-unreliable for it (e.g. a WHOOP 4.0 R-R over-count contaminating HRV).
/// nil = no caveat. Defaulted so existing call sites keep compiling unchanged.
var caveat: String? = nil
+ /// A second reading shown with the caption, under the headline value (#1636).
+ ///
+ /// Distinct from `caveat`, which says the value is unreliable; this one says what the value means.
+ /// Skin temperature is the case: the absolute leads, and the deviation it was derived from is what
+ /// makes it legible — "+0.2" says nothing without an anchor, and 34.6 °C says little without
+ /// knowing it runs high for you. Pure formatted data (a number and a unit), never a sentence.
+ /// Defaulted so existing call sites keep compiling unchanged.
+ var secondary: String? = nil
var id: String { key }
@@ -48,7 +56,9 @@ struct BodyVitalReading: Identifiable {
/// line when nothing resolved, so an empty tile still says why instead of a bare dash.
var stateCaption: String {
guard let day else { return missingCaption }
- var parts = [Self.dayLabel(day)]
+ // #1636: the secondary reading leads, so it sits directly under the headline value.
+ var parts = secondary.map { [$0] } ?? []
+ parts.append(Self.dayLabel(day))
if let sourceText = Self.sourceLabel(source, key: key) {
parts.append(sourceText)
}
@@ -123,7 +133,10 @@ enum BodyVitalSigns {
temperatureUnit: TemperatureUnit,
now: Date = Date(),
spo2CandidateByDay: [String: Double] = [:],
- hrvOverCountByDay: [String: Double] = [:]) -> [BodyVitalReading] {
+ hrvOverCountByDay: [String: Double] = [:],
+ // #1846: the Settings lead-with choice, so this tile agrees with Today and the
+ // detail screen. A setting that reaches two of three surfaces is worse than none.
+ skinTempPreferred: SkinTempDisplay.Kind = .absolute) -> [BodyVitalReading] {
let logicalDay = logicalDayKey(now)
// Resolve one metric to a per-day series, taking the FIRST source (by precedence) that carries
@@ -183,6 +196,10 @@ enum BodyVitalSigns {
let rhrPoints = points(key: "rhr") { $0.restingHr.map(Double.init) }
let hrvPoints = points(key: "hrv", \.avgHrv)
let skinPoints = points(key: "skin", \.skinTempDevC)
+ // #1636: the night's ABSOLUTE, when the strap measured one. Nights scored before that column
+ // shipped carry only the deviation and refill on the next scoring pass, so this is empty until
+ // then and everything below falls through to the deviation-led behaviour unchanged.
+ let skinAbsPoints = points(key: "skin", \.skinTempC)
let respRow = latest(respPoints)
// #103/queue-11a: fall back to the spo2_candidate mean when no calibrated spo2Pct exists. The
@@ -193,7 +210,7 @@ enum BodyVitalSigns {
let spo2rawRow = latest(spo2rawPoints)
let rhrRow = latest(rhrPoints)
let hrvRow = latest(hrvPoints)
- let skinRow = latest(skinPoints)
+ let skinRowDeviation = latest(skinPoints)
// #1118: mark HRV "unverified" when this night's in-sleep R-R was over-counted — the WHOOP 4.0
// two-optical-channel artifact that inflates R-R and contaminates RMSSD, so NOOP's HRV won't match
// WHOOP until the de-dup fix lands. The flag is written only for NOOP's OWN measured capture (an
@@ -214,13 +231,34 @@ enum BodyVitalSigns {
// Skin temp is bimodal: CSV imports store ABSOLUTE °C, the on-device pipeline a ±°C DEVIATION —
// partition the history to the displayed value's kind and pick the matching config + population
// fallback (±0.6 °C mirrors the illness watch's flag threshold).
+ //
+ // #1636: resolve the DISPLAYED night first — the freshest that carries either reading — then
+ // lead with its absolute if it has one. Asking "does the row I am already showing have an
+ // absolute?" is what keeps the tile from silently stepping back to an older night: an
+ // import-only night has a deviation and no absolute, and a CALIBRATING night has the reverse
+ // (`recomputeSkinTempDev` returns nil until the baseline is usable, while the absolute is
+ // already measured — those wearers read "needs ~4 worn nights" today with a real temperature
+ // sitting unshown behind it).
+ let skinAbsCandidate = latest(skinAbsPoints)
+ let skinRowDay: String? = [skinRowDeviation?.day, skinAbsCandidate?.day].compactMap { $0 }.max()
+ let skinAbsCandidateOnDay = skinAbsCandidate.flatMap { $0.day == skinRowDay ? $0 : nil }
+ // #1846: the same `leadReading` rule the other surfaces use — the preference picks which number is
+ // tried first, the other stays the fallback, so choosing one never empties the tile.
+ let skinLeadsAbsolute = SkinTempDisplay.leadReading(absC: skinAbsCandidateOnDay?.value,
+ devC: skinRowDeviation?.value,
+ prefer: skinTempPreferred)?.kind == .absolute
+ let skinAbsRow = skinLeadsAbsolute ? skinAbsCandidateOnDay : nil
+ let skinRow = skinAbsRow ?? skinRowDeviation ?? skinAbsCandidateOnDay
let skin = skinRow?.value
- let skinIsAbsolute = skin.map(VitalBands.isAbsoluteSkinTemp) ?? true
+ let skinIsAbsolute = skinAbsRow != nil || (skin.map(VitalBands.isAbsoluteSkinTemp) ?? true)
+ // The series the tile is actually showing — banding and the sparkline must both read from it, or
+ // an absolute would be scored against a history of deviations (#1636).
+ let skinSeries = skinAbsRow != nil ? skinAbsPoints : skinPoints
let skinResult: VitalBands.Result
if let skin {
skinResult = VitalBands.band(
value: skin,
- history: VitalBands.skinTempHistory(matching: skin, in: history(before: skinRow?.day, skinPoints)),
+ history: VitalBands.skinTempHistory(matching: skin, in: history(before: skinRow?.day, skinSeries)),
populationRange: skinIsAbsolute ? 33...36 : (-0.6)...0.6,
cfg: skinIsAbsolute ? Baselines.metricCfg["skin_temp"]! : VitalBands.skinTempDeviationCfg
)
@@ -239,6 +277,15 @@ enum BodyVitalSigns {
let skinFormat: (Double) -> String = { c in
SkinTempDisplay.numberString(c, kind: skinKind, fahrenheit: fahrenheit, decimals: 1)
}
+ // #1636: the deviation for THE DISPLAYED NIGHT — not the freshest one anywhere. A calibrating
+ // night carries an absolute and no deviation, and reaching for the latest deviation there would
+ // print a previous night's number under tonight's temperature.
+ let skinSecondary: String? = skinAbsRow == nil ? nil
+ : skinPoints.last(where: { $0.day == skinRow?.day }).map { dev in
+ let n = SkinTempDisplay.numberString(dev.value, kind: .deviation,
+ fahrenheit: fahrenheit, decimals: 1)
+ return "\(n) \(SkinTempDisplay.unitSymbol(kind: .deviation, fahrenheit: fahrenheit))"
+ }
return [
BodyVitalReading(
@@ -371,7 +418,10 @@ enum BodyVitalSigns {
missingCaption: String(localized: "No nightly skin-temp yet — needs ~4 worn nights (or import a WHOOP CSV)"),
// Keep the trail on the displayed value's kind — absolute °C and ±deviation must not
// mix on one sparkline (matches the banding partition above).
- sparkline: trail(skinPoints.filter { VitalBands.isAbsoluteSkinTemp($0.value) == skinIsAbsolute })
+ sparkline: trail(skinSeries.filter { VitalBands.isAbsoluteSkinTemp($0.value) == skinIsAbsolute }),
+ // #1636: the deviation this absolute was derived from, shown beneath it. Only when the
+ // headline IS the absolute — on a deviation-led tile it would just repeat the value.
+ secondary: skinSecondary
),
]
}
diff --git a/Strand/Screens/WorkoutDetailView.swift b/Strand/Screens/WorkoutDetailView.swift
index 7a7bb1550a..373a874e97 100644
--- a/Strand/Screens/WorkoutDetailView.swift
+++ b/Strand/Screens/WorkoutDetailView.swift
@@ -35,7 +35,12 @@ struct WorkoutDetailView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
- private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(
+ system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: distanceSystemRaw)
+ }
@AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue
private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) }
@@ -280,7 +285,7 @@ struct WorkoutDetailView: View {
@ViewBuilder private var routeCard: some View {
if route.count >= 2 {
VStack(alignment: .leading, spacing: NoopMetrics.gap) {
- SectionHeader("Route", overline: "Recorded on device",
+ SectionHeader("Route", overline: routeOriginLabel,
trailing: distanceLabel(row.distanceM))
NoopCard(padding: 0, tint: StrandPalette.effortColor) {
VStack(alignment: .leading, spacing: 0) {
@@ -298,7 +303,7 @@ struct WorkoutDetailView: View {
.padding(NoopMetrics.cardPadding)
}
}
- Text("Your GPS route for this session, recorded and stored on your device. Nothing leaves your phone.")
+ Text(routeDescription)
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -361,12 +366,7 @@ struct WorkoutDetailView: View {
let secs = row.durationS ?? Double(row.endTs - row.startTs)
guard secs > 0 else { return "–" }
let km = m / 1000.0
- let (perUnit, label): (Double, String) = unitSystem == .imperial
- ? (km * UnitFormatter.milesPerKilometer, "/mi")
- : (km, "/km")
- guard perUnit > 0 else { return "–" }
- let secsPerUnit = Int((secs / perUnit).rounded())
- return "\(secsPerUnit / 60):\(String(format: "%02d", secsPerUnit % 60)) \(label)"
+ return UnitFormatter.paceFromSecPerKm(secs / km, system: distanceUnitSystem)
}
private var routeAccessibilityLabel: String {
@@ -374,6 +374,34 @@ struct WorkoutDetailView: View {
return String(localized: "Map of your \(WorkoutSource.displaySport(row.sport)) route, \(dist).")
}
+ /// #1205: the route card's overline and description must be honest about where the route came
+ /// from. An on-device recorded route says "Recorded on device"; an imported route (Apple Health
+ /// or Health Connect) says "Imported from Apple Health" / "Imported" so the user is not told
+ /// their phone recorded GPS data that actually came from another app.
+ private var routeOriginLabel: LocalizedStringKey {
+ switch WorkoutSource.classify(row.source) {
+ // "Imported" rather than naming the app: every string here is one the catalog already carries in
+ // all nine locales, so the honesty fix ships translated on day one. A source-specific variant
+ // ("Imported from Apple Health") would be a NEW key, and nothing catches a missing entry: the i18n
+ // audit checks locale coverage OF catalog entries, not that a `String(localized:)` literal has one.
+ // It would have read English on every non-English device while the gate stayed green.
+ case .apple, .whoop, .lifting, .activityFile: return "Imported"
+ case .detected, .manual: return "Recorded on device"
+ }
+ }
+
+ private var routeDescription: String {
+ switch WorkoutSource.classify(row.source) {
+ // Same rule as the overline: both of these are existing catalog keys with all nine locales. The
+ // imported line carries the privacy claim without the "recorded on your device" the original
+ // string opens with, which is the part that was untrue for a route another app collected.
+ case .apple, .whoop, .lifting, .activityFile:
+ return String(localized: "This stays on your device. It is never uploaded, never synced, never shared.")
+ case .detected, .manual:
+ return String(localized: "Your GPS route for this session, recorded and stored on your device. Nothing leaves your phone.")
+ }
+ }
+
// MARK: - HR curve
@ViewBuilder private var hrCurveCard: some View {
@@ -573,18 +601,12 @@ struct WorkoutDetailView: View {
f.dateFormat = "EEEE d MMM yyyy"
return f
}()
- private static let timeFmt: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.setLocalizedDateFormatFromTemplate("jmm")
- return f
- }()
- private static let tooltipTime: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.setLocalizedDateFormatFromTemplate("jmm")
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() }
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var tooltipTime: DateFormatter { AppClock.hourMinuteFormatter() }
private func dateLabel(_ ts: Int) -> String {
Self.dateFmt.string(from: Date(timeIntervalSince1970: TimeInterval(ts)))
@@ -604,7 +626,7 @@ struct WorkoutDetailView: View {
}
private func distanceLabel(_ m: Double?) -> String {
guard let m, m > 0 else { return "–" }
- return UnitFormatter.distanceFromMeters(m, system: unitSystem)
+ return UnitFormatter.distanceFromMeters(m, system: distanceUnitSystem)
}
private func grouped(_ v: Double) -> String {
Self.intFmt.string(from: NSNumber(value: Int(v.rounded()))) ?? "\(Int(v.rounded()))"
diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift
index 84f3bbd289..d20eea588f 100644
--- a/Strand/Screens/WorkoutsView.swift
+++ b/Strand/Screens/WorkoutsView.swift
@@ -33,10 +33,14 @@ struct WorkoutsView: View {
@State private var showLiveWorkout = false
@State private var showStartSport = false
- // Imperial/Metric display preference (D#103). Workout distances are stored in metres; the toggle
- // re-labels them to miles/yards. Display-only — nothing on disk changes.
+ // Exercise-distance preference (#1913). Unset follows the original combined preference.
@AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
- private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric }
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(
+ system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: distanceSystemRaw)
+ }
// Effort display scale (#268) — drives the effort hero's read-out. Display-only.
@AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue
@@ -972,7 +976,7 @@ struct WorkoutsView: View {
caption: "kcal",
accent: StrandPalette.metricAmber)
StatTile(label: "Total Distance",
- value: distancesM.isEmpty ? "–" : UnitFormatter.distanceFromKilometers(totalKmRaw, system: unitSystem),
+ value: distancesM.isEmpty ? "–" : UnitFormatter.distanceFromKilometers(totalKmRaw, system: distanceUnitSystem),
caption: String(localized: "covered"),
accent: StrandPalette.metricCyan)
StatTile(label: "Most Active",
@@ -1735,12 +1739,9 @@ struct WorkoutsView: View {
// The "jmm" skeleton respects the device's 12-/24-hour setting (#337): "4:34 PM" where 12-hour is
// preferred, "16:34" where 24-hour is — instead of forcing 24-hour on everyone (matches TodayView).
- private static let timeFmt: DateFormatter = {
- let f = DateFormatter()
- f.locale = AppLanguage.activeLocale
- f.setLocalizedDateFormatFromTemplate("jmm")
- return f
- }()
+ /// #1821: routed through AppClock so the Clock format setting reaches this label. Was a `static
+ /// let`, which would have frozen the reader's choice at first use until the app relaunched.
+ private static var timeFmt: DateFormatter { AppClock.hourMinuteFormatter() }
private func dateLabel(_ ts: Int) -> String {
Self.dateFmt.string(from: Date(timeIntervalSince1970: TimeInterval(ts)))
@@ -1772,7 +1773,7 @@ struct WorkoutsView: View {
private func distanceLabel(_ m: Double?) -> String {
guard let m, m > 0 else { return "–" }
- return UnitFormatter.distanceFromMeters(m, system: unitSystem)
+ return UnitFormatter.distanceFromMeters(m, system: distanceUnitSystem)
}
private func oneDecimal(_ v: Double) -> String { String(format: "%.1f", v) }
diff --git a/Strand/Screens/XiaomiBandView.swift b/Strand/Screens/XiaomiBandView.swift
index fa0bef23ec..e98b4e77c7 100644
--- a/Strand/Screens/XiaomiBandView.swift
+++ b/Strand/Screens/XiaomiBandView.swift
@@ -19,6 +19,13 @@ private struct ChartWidthKey: PreferenceKey {
struct XiaomiBandView: View {
@EnvironmentObject var repo: Repository
+ @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue
+ @AppStorage(UnitPrefs.distanceSystemKey) private var distanceSystemRaw = ""
+ private var distanceUnitSystem: UnitSystem {
+ UnitPrefs.resolveDistance(
+ system: UnitSystem(rawValue: unitSystemRaw) ?? .metric,
+ override: distanceSystemRaw)
+ }
/// Per-source partition key — matches `XiaomiImporter.deviceId`.
private static let source = "xiaomi-band"
@@ -165,7 +172,9 @@ struct XiaomiBandView: View {
.chart("Sleep score", "sleep_score", accentGradient, 0...100, { "\(Int($0.rounded()))" }),
.header("Activity & Energy", "Movement"),
.chart("Steps", "steps", cyanGradient, 0...12000, { intString($0) }),
- .chart("Distance", "distance_m", cyanGradient, 0...10000, { String(format: "%.2f km", $0 / 1000) }),
+ .chart("Distance", "distance_m", cyanGradient, 0...10000, {
+ UnitFormatter.distanceFromMeters($0, system: distanceUnitSystem)
+ }),
.chart("Active energy", "energy_kcal", amberGradient, 0...1000, { "\(intString($0)) kcal" }),
.chart("Intensity minutes", "intensity_min", amberGradient, 0...120, { "\(Int($0.rounded())) min" }),
.header("Wellbeing", "Body energy"),
diff --git a/Strand/System/AppChangelog.swift b/Strand/System/AppChangelog.swift
index 28202c47c9..7b42f96893 100644
--- a/Strand/System/AppChangelog.swift
+++ b/Strand/System/AppChangelog.swift
@@ -7,7 +7,7 @@ enum AppChangelog {
/// Bump this when you add a release below. The "What's New" sheet shows automatically when the
/// stored last-seen version is behind this. (Decoupled from the bundle version on purpose.)
- static let currentVersion = "10.6.0"
+ static let currentVersion = "11.1.0"
struct Release: Identifiable {
let version: String
@@ -19,6 +19,35 @@ enum AppChangelog {
/// Newest first.
static let releases: [Release] = [
+ Release(
+ version: "11.1.0",
+ title: "Choose a 12-hour clock, sleep from straps that bank no motion, and a strap log that stops guessing",
+ date: "September 2026",
+ items: [
+ "**Pick the clock you read times in (#1821).** Settings → Appearance now offers System, 12-hour or 24-hour. It defaults to System, so nothing changes unless you ask — and System now means your phone's own 24-hour switch, which NOOP was previously ignoring in favour of your region's default. A reader in a 24-hour country who prefers 12-hour had no way to say so.",
+ "**Sleep from a strap that records no motion (#1801).** A WHOOP 5/MG that never pairs banks no movement data, and sleep detection is built on stillness — so those nights scored as nothing at all. NOOP can now find a night from heart rate alone and stage it. It is display-only by design: an HR-only night never feeds your resting heart rate or HRV baselines, because it has not earned that.",
+ "**A charged strap is no longer told to charge (#1818).** The 1970/71 clock warning offered one remedy regardless of battery, so people at 100% were sent round a loop they had already run. It now says something true for a strap that is already charged, and asks for the log that can actually explain it.",
+ "**The coach can see your sleep stages (#1816).** It was answering that it had no access to them, because deep, REM and light minutes were never in what it was given. They are now, along with sleep efficiency.",
+ "**Back up a database larger than NOOP would restore (#1807).** Export warns when an archive is past the 2 GiB restore ceiling instead of writing it silently, and a restore can be allowed through rather than refused outright.",
+ "**Home-screen widget corrections (#1795, thanks @Sneheth; #1799).** The heart-rate and HRV values sat under each other's icons on Apple. Both platforms' widgets now also read properly aloud, with the value spoken rather than the raw number.",
+ "**Live workout no longer stacks two timers (#1814), and the steps card stops asking for the half you already gave it (#1815).**",
+ "**A strap log that reports rather than assumes (#1809, thanks @supremesynergy; #1823).** Every disconnect now records how long the link held and whether the strap sent anything at all, and the clock exchange quotes what the strap actually answered. NOOP used to write \"clock synced\" the moment it queued the write, before any reply existed — so a log could insist the clock was set while the screen said 1970/71.",
+ "**Oura rings with no name, and Android's heart-rate logging (#1797, thanks @pipiche38; #1796, thanks @kvnloo).**",
+ ]
+ ),
+ Release(
+ version: "11.0.0",
+ title: "A WHOOP 5 that stays connected, your body clock on the Sleep screen, and a Journal that knows No from nothing",
+ date: "September 2026",
+ items: [
+ "**The WHOOP 5.0 and MG stop dropping every few seconds (#1635, thanks @Zebsi235).** A handshake the strap never answers was knocking a perfectly good link down about every five seconds, all day. NOOP now recognises a strap that will not complete that handshake, stops attempting it, and holds the link instead — live heart rate keeps streaming rather than restarting forever. Tapping Connect costs one reconnect now, not five.",
+ "**Your body clock, on the Sleep screen (#1722, #1723, #1729, #1733).** A 24-hour dial showing when your body actually wants to sleep, and a chronotype read from your own nights rather than a questionnaire. It says what it used and refuses to guess when it cannot see enough.",
+ "**Nights read straighter (#1717, #1734, #1741, #1755, #1782).** A night that only partly downloaded is no longer scored as if it were whole. Sleep that arrives in fragments is stitched back together when your heart rate says you stayed asleep through the gap. And sleep debt is now a number you can act on tonight.",
+ "**A day you never logged is not a day you answered No (#322).** Journal insights compared days you ticked against every other day — including the ones you simply did not open the app. They now compare Yes days against No days only, so an untracked week stops quietly counting against you.",
+ "**NOOP tells you when there is a new version (#1674, #1675).** Both platforms now notice a release and say so, on by default, checked directly against the release feed with nothing else sent.",
+ "**Health Connect asks for one category at a time (#1509, thanks @kavemang).** Recovery, Activity and Body composition are chosen before Android's prompt appears, so you grant what you meant to. Existing installs keep exactly what they already had.",
+ ]
+ ),
Release(
version: "10.6.0",
title: "An Effort scale you choose, a ring that gets to sleep, and far fewer wasted re-scores",
diff --git a/Strand/System/CoachBriefScheduler.swift b/Strand/System/CoachBriefScheduler.swift
new file mode 100644
index 0000000000..df4ba40bc7
--- /dev/null
+++ b/Strand/System/CoachBriefScheduler.swift
@@ -0,0 +1,370 @@
+import Foundation
+import UserNotifications
+#if os(iOS)
+import BackgroundTasks
+import WidgetKit
+#endif
+
+/// PRD-K5: a user-scheduled, LOCAL notification that delivers a one-line morning coaching brief,
+/// generated on-device via the user's already-configured Coach provider, at a chosen time each day.
+/// Tap → opens Coach with the full brief. No push server, no cloud — the network call is the SAME
+/// bring-your-own-key request Coach already makes on every send, just user-armed on a daily timer
+/// instead of triggered by a tap. Default OFF, like every NOOP automation.
+///
+/// Cross-platform (macOS + iOS), same architecture as `ScheduledDebugExport`:
+/// - **macOS** — the app is usually running; a foreground `DispatchSourceTimer` fires at the chosen
+/// minute, and `catchUpIfDue` covers the time having passed while the app wasn't open.
+/// - **iOS** — a sideloaded, backgrounded app can't guarantee a wake at the exact minute. A
+/// `BGAppRefreshTaskRequest` is submitted for *no earlier than* the chosen time; iOS decides when it
+/// actually runs. If the slot is missed entirely (app never woken), the next foreground open's
+/// `activateIfEnabled` catches up and generates then instead — same honest "best-effort" posture
+/// `ScheduledDebugExport`'s Settings copy already uses.
+///
+/// Triple-gated network call: `enabled` (this feature, default OFF) + a saved Coach key + the existing
+/// `dataConsent`. `generateBrief` is `AICoachEngine.generateBrief()`, itself gated on both of those; this
+/// scheduler additionally never marks a day "done" on a failed generation, so a missing key/consent (or
+/// a transient network failure) simply retries next wake rather than going permanently silent.
+@MainActor
+enum CoachBriefScheduler {
+
+ // MARK: - Persisted settings (own keys; mirrors ScheduledDebugExport's shape)
+
+ private enum K {
+ static let enabled = "coachBrief.enabled" // master enable; default OFF
+ static let time = "coachBrief.timeMinutes" // minutes since local midnight; default 07:00
+ static let lastRun = "coachBrief.lastRunDayKey" // yyyy-MM-dd of the last GENERATED brief (dedup)
+ static let storedBrief = "coachBrief.storedText" // the full brief, for the notification tap-through
+ static let hasUnconsumed = "coachBrief.hasUnconsumedBrief"
+ // K10: the brief text mirrored into the App Group so the widget extension (separate process)
+ // can read it. The app's own UserDefaults.standard isn't visible to the widget.
+ static let widgetBriefKey = "coachBrief.widgetText"
+ static let widgetBriefDateKey = "coachBrief.widgetDate"
+ }
+
+ /// 07:00 — a brief waiting when you check your phone (matches `ScheduledDebugExport`'s default).
+ static let defaultTimeMinutes = 7 * 60
+ private static let minutesPerDay = 24 * 60
+
+ /// Category id the posted notification carries, so `NotificationPresenter` can recognise a brief tap
+ /// (as opposed to any other local notification) and route it to Coach.
+ static let notificationCategoryId = "coach-brief"
+ private static let requestIdPrefix = "coach-brief-"
+
+ static var isEnabled: Bool { UserDefaults.standard.bool(forKey: K.enabled) }
+
+ /// Time-of-day to generate, minutes since local midnight. Clamped to a valid minute. Default 07:00.
+ static var timeMinutes: Int {
+ let v = UserDefaults.standard.object(forKey: K.time) as? Int ?? defaultTimeMinutes
+ return min(max(v, 0), minutesPerDay - 1)
+ }
+
+ /// The most recently generated full brief text (for the notification tap-through), or nil before the
+ /// first successful generation.
+ static var storedBrief: String? { UserDefaults.standard.string(forKey: K.storedBrief) }
+
+ /// True when a generated brief hasn't yet been surfaced in the Coach transcript. Set right after a
+ /// successful generation; cleared by `consumeStoredBrief()`.
+ static var hasUnconsumedBrief: Bool { UserDefaults.standard.bool(forKey: K.hasUnconsumed) }
+
+ /// Coach reads this once (on appear, with an empty transcript) to surface the stored brief as its
+ /// first message without re-generating or re-sending anything. Returns nil (and leaves state
+ /// untouched) when there's nothing unconsumed, so a normal open is unaffected.
+ static func consumeStoredBrief() -> String? {
+ guard hasUnconsumedBrief, let text = storedBrief else { return nil }
+ UserDefaults.standard.set(false, forKey: K.hasUnconsumed)
+ return text
+ }
+
+ // MARK: - K10: Widget-facing API (App Group, iOS only)
+
+ #if os(iOS)
+ /// The stored brief text for the widget (App Group), or nil before the first successful generation.
+ /// The widget extension reads this — it can't see the app's `UserDefaults.standard`.
+ static var widgetBriefText: String? {
+ UserDefaults(suiteName: WidgetSnapshot.suiteName)?.string(forKey: K.widgetBriefKey)
+ }
+
+ /// Wall-clock date the widget brief was generated, so the widget can show honest staleness.
+ static var widgetBriefDate: Date? {
+ UserDefaults(suiteName: WidgetSnapshot.suiteName)?.object(forKey: K.widgetBriefDateKey) as? Date
+ }
+
+ /// Publish (or clear) the brief into the App Group so the widget extension can read it. Called
+ /// from `catchUpIfDue` / `generateNow` after a successful generation, and from `setEnabled(false)`
+ /// to clear the widget when the user turns the feature off.
+ static func publishToWidget(_ text: String?) {
+ guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName) else { return }
+ if let text {
+ defaults.set(text, forKey: K.widgetBriefKey)
+ defaults.set(Date(), forKey: K.widgetBriefDateKey)
+ } else {
+ defaults.removeObject(forKey: K.widgetBriefKey)
+ defaults.removeObject(forKey: K.widgetBriefDateKey)
+ }
+ // Nudge WidgetKit to reload the coach-brief widget's timeline so it picks up the new text
+ // without waiting for the next scheduled refresh. The kind string must match the widget's
+ // `kind` property (see CoachBriefWidget.swift in StrandiOSWidgets).
+ WidgetCenter.shared.reloadTimelines(ofKind: Self.widgetKind)
+ }
+ #endif
+
+ /// The WidgetKit kind string for the coach-brief widget. Must match the `kind` property on
+ /// `CoachBriefWidget` in `StrandiOSWidgets/`. Declared here so `publishToWidget` can reload
+ /// timelines without importing the widget extension's type (the app target can't see it).
+ static let widgetKind = "CoachBriefWidget"
+
+ // MARK: - Enable / configure (Settings calls these)
+
+ /// The result of enabling the schedule — mirrors `WindDownNudge.EnableOutcome` so the UI can revert
+ /// the toggle and point at Settings on denial instead of persisting a dead "on" state.
+ enum EnableOutcome { case scheduled, denied, off }
+
+ /// Enable/disable and (re)schedule. Enabling gates on notification authorization first (asks once if
+ /// undetermined; reports `.denied` rather than silently scheduling something the OS will never
+ /// deliver). `completion` always runs on the main actor.
+ static func setEnabled(_ on: Bool,
+ generateBrief: @escaping () async -> String?,
+ completion: (@MainActor (EnableOutcome) -> Void)? = nil) {
+ guard on else {
+ UserDefaults.standard.set(false, forKey: K.enabled)
+ cancel()
+ #if os(iOS)
+ publishToWidget(nil) // K10: clear the widget when the feature is turned off
+ #endif
+ completion?(.off)
+ return
+ }
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
+ Task { @MainActor in
+ switch settings.authorizationStatus {
+ case .authorized, .provisional, .ephemeral:
+ UserDefaults.standard.set(true, forKey: K.enabled)
+ scheduleNext(generateBrief: generateBrief)
+ completion?(.scheduled)
+ case .notDetermined:
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
+ Task { @MainActor in
+ if granted {
+ UserDefaults.standard.set(true, forKey: K.enabled)
+ scheduleNext(generateBrief: generateBrief)
+ completion?(.scheduled)
+ } else {
+ UserDefaults.standard.set(false, forKey: K.enabled)
+ completion?(.denied)
+ }
+ }
+ }
+ default:
+ UserDefaults.standard.set(false, forKey: K.enabled)
+ completion?(.denied)
+ }
+ }
+ }
+ }
+
+ /// Update the time-of-day and reschedule so the new time takes effect immediately.
+ static func setTimeMinutes(_ minutes: Int, generateBrief: @escaping () async -> String?) {
+ UserDefaults.standard.set(min(max(minutes, 0), minutesPerDay - 1), forKey: K.time)
+ if isEnabled { scheduleNext(generateBrief: generateBrief) }
+ }
+
+ /// Call whenever Coach (or its settings) appears so the schedule self-heals (re-arms the macOS timer
+ /// after a relaunch, re-submits the iOS request) and a slot missed while the app wasn't open still
+ /// generates once. No-op when the feature is off.
+ static func activateIfEnabled(generateBrief: @escaping () async -> String?) {
+ guard isEnabled else { return }
+ scheduleNext(generateBrief: generateBrief)
+ Task { await catchUpIfDue(generateBrief: generateBrief) }
+ }
+
+ /// The explicit "Generate now" button: always generates and stores, ignoring the once-per-day dedup,
+ /// so a tap produces a fresh brief there and then. Does NOT post a notification (the user is already
+ /// looking at Coach) and does NOT touch `lastRun`, so today's scheduled slot still fires normally.
+ /// Returns the brief text, or nil on failure (no key/consent/network) — the caller surfaces that.
+ @discardableResult
+ static func generateNow(generateBrief: () async -> String?) async -> String? {
+ await generateBrief()
+ }
+
+ // MARK: - Due check + generation
+
+ /// If today's brief is due (we're at/after the chosen time and haven't generated today), generate it
+ /// once: store the text, mark today done, and post the notification. Covers macOS launches where the
+ /// time passed while the app wasn't open, and the iOS foreground/BGTask paths.
+ ///
+ /// Returns whether this call can be considered successful: `true` when there was nothing to do
+ /// (feature off, not yet time, or already ran today) or generation succeeded; `false` only when
+ /// generation was attempted and failed (so the iOS BGTask handler can report the real outcome and
+ /// retry on the next wake instead of marking the day done on a failure).
+ @discardableResult
+ static func catchUpIfDue(generateBrief: () async -> String?) async -> Bool {
+ guard isEnabled else { return true }
+ let now = Date()
+ let cal = Calendar.current
+ let comps = cal.dateComponents([.hour, .minute], from: now)
+ let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
+ guard nowMinutes >= timeMinutes else { return true } // not yet time today
+ guard UserDefaults.standard.string(forKey: K.lastRun) != dayKey(now) else { return true } // already ran today
+
+ guard let text = await generateBrief() else {
+ // No key/consent/network, or the provider returned nothing. Never mark the day done, so the
+ // next wake (BGTask retry, or the next foreground open) tries again. Post a low-key retry
+ // notification rather than silently doing nothing — the PRD's "unavailable, tap to retry".
+ postNotification(title: String(localized: "Coach brief unavailable"),
+ body: String(localized: "Couldn't generate today's brief. Tap to try again in Coach."),
+ isRetry: true)
+ return false
+ }
+
+ UserDefaults.standard.set(dayKey(now), forKey: K.lastRun)
+ UserDefaults.standard.set(text, forKey: K.storedBrief)
+ UserDefaults.standard.set(true, forKey: K.hasUnconsumed)
+ #if os(iOS)
+ publishToWidget(text) // K10: mirror into the App Group for the widget
+ #endif
+ postNotification(title: String(localized: "Today's coaching brief"),
+ body: oneLineSummary(from: text))
+ return true
+ }
+
+ /// Pure: the notification body is the brief's first non-empty line, trimmed and length-capped so it
+ /// fits a notification banner. Unit-testable without any I/O.
+ static func oneLineSummary(from brief: String, maxLength: Int = 120) -> String {
+ let firstLine = brief
+ .split(separator: "\n", omittingEmptySubsequences: true)
+ .first
+ .map(String.init) ?? brief
+ let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.count > maxLength else { return trimmed }
+ let cut = trimmed.index(trimmed.startIndex, offsetBy: maxLength)
+ return trimmed[.. String {
+ let f = DateFormatter()
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.dateFormat = "yyyy-MM-dd"
+ return f.string(from: date)
+ }
+
+ /// Seconds from now until the next wall-clock occurrence of `minuteOfDay` (today if still ahead, else
+ /// tomorrow). Mirrors `ScheduledDebugExport.secondsToNextOccurrence`.
+ private static func secondsToNextOccurrence(_ minuteOfDay: Int, now: Date = Date()) -> TimeInterval {
+ let cal = Calendar.current
+ var comps = cal.dateComponents([.year, .month, .day], from: now)
+ comps.hour = minuteOfDay / 60
+ comps.minute = minuteOfDay % 60
+ comps.second = 0
+ var target = cal.date(from: comps) ?? now
+ if target <= now { target = cal.date(byAdding: .day, value: 1, to: target) ?? target }
+ return max(1, target.timeIntervalSince(now))
+ }
+
+ // MARK: - Scheduling
+
+ private static var macTimer: DispatchSourceTimer?
+
+ /// (Re)arm the next occurrence. macOS uses a foreground `DispatchSourceTimer`; iOS submits a
+ /// background-refresh request. Both target the next wall-clock occurrence of `timeMinutes`.
+ private static func scheduleNext(generateBrief: @escaping () async -> String?) {
+ #if os(macOS)
+ macTimer?.cancel()
+ let timer = DispatchSource.makeTimerSource(queue: .main)
+ let delay = secondsToNextOccurrence(timeMinutes)
+ timer.schedule(deadline: .now() + delay)
+ timer.setEventHandler {
+ Task { @MainActor in
+ guard isEnabled else { return }
+ _ = await catchUpIfDue(generateBrief: generateBrief)
+ scheduleNext(generateBrief: generateBrief)
+ }
+ }
+ timer.resume()
+ macTimer = timer
+ #elseif os(iOS)
+ submitBackgroundRequest()
+ #endif
+ }
+
+ /// Cancel any armed schedule.
+ private static func cancel() {
+ #if os(macOS)
+ macTimer?.cancel()
+ macTimer = nil
+ #elseif os(iOS)
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: bgTaskIdentifier)
+ #endif
+ }
+
+ // MARK: - iOS background task plumbing
+
+ #if os(iOS)
+ /// iOS BGTask identifier, derived from the running bundle id so it tracks `BUNDLE_ID_PREFIX` and
+ /// matches the iOS target's `BGTaskSchedulerPermittedIdentifiers` (Info.plist / project.yml).
+ static let bgTaskIdentifier = (Bundle.main.bundleIdentifier ?? "com.noopapp.noop") + ".coachbrief"
+
+ /// Register the BGTask handler. MUST be called from the app's launch (before launch finishes) — call
+ /// this from `StrandiOSApp.init()` with `{ [weak coach] in await coach?.generateBrief() }`. Safe to
+ /// leave uncalled: `submitBackgroundRequest()` then fails gracefully and the foreground catch-up
+ /// (`activateIfEnabled`, called from Coach's `.task`) still generates on next open.
+ static func register(generateBrief: @escaping () async -> String?) {
+ BGTaskScheduler.shared.register(forTaskWithIdentifier: bgTaskIdentifier, using: nil) { task in
+ let completion = TaskCompletionGuard(task: task)
+ let worker = Task { @MainActor in
+ task.expirationHandler = { completion.finish(success: false) }
+ guard isEnabled else {
+ completion.finish(success: true)
+ return
+ }
+ let succeeded = await catchUpIfDue(generateBrief: generateBrief)
+ // Single-shot; request the next occurrence regardless of this run's outcome so a failed
+ // generation still retries at the next wake rather than going silent for good.
+ submitBackgroundRequest()
+ completion.finish(success: succeeded)
+ }
+ task.expirationHandler = { worker.cancel(); completion.finish(success: false) }
+ }
+ }
+
+ /// `BGTask` completion is single-shot even when the normal path races expiration.
+ private final class TaskCompletionGuard: @unchecked Sendable {
+ private let task: BGTask
+ private let lock = NSLock()
+ private var finished = false
+
+ init(task: BGTask) { self.task = task }
+
+ func finish(success: Bool) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard !finished else { return }
+ finished = true
+ task.setTaskCompleted(success: success)
+ task.expirationHandler = nil
+ }
+ }
+
+ /// Submit a background-refresh request for *no earlier than* the next chosen time. iOS decides when
+ /// (and whether) to actually run it — best-effort, same honesty as `ScheduledDebugExport`.
+ private static func submitBackgroundRequest() {
+ let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier)
+ request.earliestBeginDate = Date(timeIntervalSinceNow: secondsToNextOccurrence(timeMinutes))
+ try? BGTaskScheduler.shared.submit(request)
+ }
+ #endif
+}
diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift
index 4287e75d27..87dd6bcfeb 100644
--- a/Strand/System/DebugDataDiagnostics.swift
+++ b/Strand/System/DebugDataDiagnostics.swift
@@ -22,6 +22,30 @@ enum DebugDataDiagnostics {
/// Strap identity + timezone from persisted defaults (sync, offline-safe). Mirrors the prefs-backed
/// portion of the Android strap-state block; keys match the iOS @AppStorage / persisted values.
+
+ /// What the active strap actually delivered over the window — the line that says which scores can
+ /// exist at all.
+ ///
+ /// A 5/MG that never completes its handshake streams live HR and R-R over the standard characteristic
+ /// and nothing else: motion and steps arrive only with the proprietary offload. Without motion the
+ /// sleep stager has no HR-only fallback and the workout detector returns before it looks at heart
+ /// rate, so Rest reads "No data" and no bout is ever found — and until now a report showed those
+ /// absences with nothing connecting them to their single cause.
+ ///
+ /// The window is IN the label. Without it "Provides: motion NO" reads as a capability claim, and a
+ /// strap simply not worn for two days would be reported as incapable of motion — the opposite kind of
+ /// wrong from the one this line exists to prevent. Over a window of actual wear, delivered and capable
+ /// are the same thing; the label keeps that assumption visible instead of implied.
+ /// The label is padded to 13 like every other in this block ("Model:", "Offload:"), and the window
+ /// rides the VALUE. "Provides(48h):" is 15 and overhung the column in a report that is aligned by hand
+ /// and read by eye.
+ /// Byte-identical to the Kotlin `AndroidDiagnostics.strapProvidesLine`.
+ static func strapProvidesLine(hr: Bool, rr: Bool, motion: Bool, steps: Bool) -> String {
+ func mark(_ b: Bool) -> String { b ? "yes" : "NO" }
+ return "Provides: HR \(mark(hr)) · R-R \(mark(rr)) · motion \(mark(motion)) · steps \(mark(steps))"
+ + " (last 48h)"
+ }
+
static func strapStateLines() -> [String] {
var lines: [String] = []
lines.append(String(repeating: "─", count: 40))
@@ -44,22 +68,44 @@ enum DebugDataDiagnostics {
// per-device value there; this line is superseded by it and wants the same follow-up as the Apple
// write site, which has no peripheral identity to key on today.
lines.append("Firmware: \(d.string(forKey: "noop.lastFirmware") ?? "unknown (connect to record)")")
+ // NOTE: still the legacy GLOBAL key, for the same reason the firmware line above is — strapStateLines
+ // is sync and prefs-only by contract, and the per-device rule needs a registry read for pairedCount.
+ // The BLE layer therefore keeps writing the global alongside the per-device one; without that this
+ // line would not become per-device, it would simply freeze at its pre-upgrade value. Unlike
+ // firmware there is no per-device block further down to supersede it, so on a multi-strap install
+ // it can still name the OTHER strap's sync — the Kotlin twin resolves it because its export has the
+ // registry in hand. Tracked with the same follow-up.
let syncSec = d.double(forKey: "lastSyncedAt")
lines.append("Last sync: \(syncSec > 0 ? relTime(Date().timeIntervalSince1970 - syncSec) : "never")")
// #57: write-health. "Last sync" fires even on an empty/failed offload, so distinguish "rows
+
// actually landed" from "an offload STALLED on a persist failure" (history won't persist — usually a
// backup restored without an app restart, the closed-store class).
let now = Date().timeIntervalSince1970
let okAt = d.double(forKey: "sync.lastWriteOkAt")
let stalledAt = d.double(forKey: "sync.lastWriteStalledAt")
let restoreAt = d.double(forKey: "backup.lastRestoreAt")
- lines.append("Data write: \(okAt > 0 ? "rows last landed \(relTime(now - okAt))" : "no rows ever persisted")")
+ // "Offload:", not "Data write:". The stamp is written ONLY when a backfill session persists
+ // rows, so it says nothing about live streaming, and the old label read as "this app has stored
+ // nothing from your strap" on a strap that offloads nothing but streams happily. Twin of the
+ // Kotlin change.
+ lines.append("Offload: " + (okAt > 0
+ ? "rows last landed \(relTime(now - okAt))"
+ : "no history rows ever persisted (live HR/R-R are not counted here)"))
if stalledAt > 0, stalledAt >= okAt {
lines.append(" ⚠ history NOT persisting — last offload STALLED \(relTime(now - stalledAt)) "
+ "(if you restored a backup, fully restart the app — #57)")
}
if restoreAt > 0 { lines.append("Last restore: \(relTime(now - restoreAt))") }
#if os(iOS)
+ // What the home-screen widgets cost. Reported unconditionally, including the no-publish case,
+ // because the absence of widget activity is itself the answer to a drain report.
+ //
+ // iOS only: `WidgetTelemetry` lives in StrandiOSShared, which project.yml deliberately keeps
+ // OUT of the macOS application module. macOS has no home-screen widget to account for.
+ lines.append(WidgetTelemetry.snapshot().render())
+ #endif
+ #if os(iOS)
// #52: iOS Backup & Sync folder-picker health. When users report "won't let me pick a folder",
// this pins the failure stage: "cancelled"/"never used" ⇒ the picker's Open button never fired
// (an iOS-side picker issue — the in-app "Use NOOP's own folder" fallback sidesteps it);
@@ -96,6 +142,24 @@ enum DebugDataDiagnostics {
@MainActor static func dynamicLines(repo: Repository) async -> [String] {
var lines = strapStateLines()
+ // #1770 follow-up: which streams the ACTIVE strap actually delivered over the last 48 h. Four
+ // EXISTS seeks, not counts — see WhoopStore.streamPresence for why that distinction matters on a
+ // table holding ~190k motion rows a night.
+ //
+ // HERE and not in strapStateLines() beside `Offload:`, where it belongs by subject: that
+ // function is synchronous and holds neither `repo` nor a store handle. The first attempt put it
+ // there and would not have compiled — in a file the comment below already notes needs macOS to
+ // build, which is exactly why it went unnoticed locally. Appended first so the output order is
+ // still the one the reader wants.
+ if let presenceStore = await repo.storeHandle(),
+ let present = try? await presenceStore.streamPresence(
+ deviceId: repo.deviceId,
+ from: Int(Date().timeIntervalSince1970) - 48 * 3600,
+ to: Int(Date().timeIntervalSince1970)) {
+ lines.append(strapProvidesLine(hr: present.hr, rr: present.rr,
+ motion: present.gravity, steps: present.steps))
+ }
+
// Data state from the preloaded day spine.
let days = repo.days
lines.append("History: \(days.count) day rows")
@@ -199,7 +263,14 @@ enum DebugDataDiagnostics {
// back empty, so a healthy install pays nothing.
let elsewhere = ((try? await store.rawSampleCountsByDevice(from: cs.startTs, to: cs.endTs)) ?? [])
.filter { $0.0 != did }
- lines.append(orphanedSamplesLine(activeId: did, othersWithSamples: elsewhere))
+ // The registry, so a SECOND strap's night is not reported as a read failure. Only read when
+ // the active id came back empty, so a healthy install still pays nothing.
+ let otherStraps = Set(
+ ((try? DeviceRegistryStore(dbQueue: store.registryWriter).all()) ?? [])
+ .filter { $0.status != .archived && $0.id != did }
+ .map(\.id))
+ lines.append(orphanedSamplesLine(activeId: did, othersWithSamples: elsewhere,
+ otherLiveStrapIds: otherStraps))
return lines
}
if let rem = SleepStager.remFunnelDiagnostic(start: cs.startTs, end: cs.endTs, grav: grav, hr: hr, rr: rr, resp: resp) {
@@ -384,7 +455,9 @@ enum DebugDataDiagnostics {
} else if behind < -3 * 86400 {
lines.append("Strap clock: \(-behind / 86400)d AHEAD of wall (future-dated — alarm unreliable; recent sleep may be misdated, #67)")
} else {
- lines.append("Strap clock: OK")
+ // #1706: say what this measures. It reads RECORD timestamps, and sat two lines above an
+ // alarm readback claiming 2045, which reads as the two contradicting.
+ lines.append("Strap clock: OK (from record timestamps, not the alarm readback)")
}
}
if let sent = d.object(forKey: "alarm.lastArmSentEpoch") as? Int {
@@ -406,14 +479,23 @@ enum DebugDataDiagnostics {
}
lines.append(line)
if let reported = d.object(forKey: "alarm.lastReportedEpoch") as? Int {
- let mismatch = abs(reported - sent) > 120
- var rline = "Strap reports: \(alarmStamp(reported))"
- + (mismatch ? " ⚠️ MISMATCH — strap didn't accept the time" : " ✓ matches")
+ // #1706: only judge when both halves are known to be the SAME strap, otherwise this
+ // blames a device that was never asked.
+ let verdict = AlarmReadback.verdict(
+ sentEpoch: sent,
+ reportedEpoch: reported,
+ sentDeviceId: d.string(forKey: "alarm.lastArmDeviceId"),
+ reportedDeviceId: d.string(forKey: "alarm.lastReportedDeviceId"))
+ var rline = "Strap reports: \(alarmStamp(reported))" + AlarmReadback.suffix(verdict)
// #34: consecutive rejections — a persistent refusal (vs a one-off) points at a strap whose
// alarm register needs a reset, and is what SmartAlarmView warns the user about at ≥2.
let streak = d.integer(forKey: "alarm.rejectStreak")
if streak >= 2 { rline += " · \(streak) in a row (register likely needs a reset, #34)" }
lines.append(rline)
+ // The bytes the epoch was decoded from: what tells a stored stale alarm from a misdecode.
+ if let raw = d.string(forKey: "alarm.lastReportedRaw"), !raw.isEmpty {
+ lines.append("Readback frame: \(raw)")
+ }
} else {
lines.append("Strap reports: (no readback)")
}
@@ -476,11 +558,35 @@ enum DebugDataDiagnostics {
///
/// Pure so the wording is unit-tested without a database, a strap, or a registry. Kotlin twin:
/// `com.noop.testcentre.orphanedSamplesLine`.
- static func orphanedSamplesLine(activeId: String, othersWithSamples: [(String, Int)]) -> String {
+ /// `otherLiveStrapIds` is the registered, non-archived device ids OTHER than the active one. It exists
+ /// because the "not being read" wording was itself an over-assertion — the mirror image of the one it
+ /// replaced. A wearer with TWO straps has nights owned by the other one, and `DayOwnerResolver` hands
+ /// each day to whichever device actually holds its data. Samples under another id are then completely
+ /// normal, and calling that a read failure sends the reader hunting a bug that is not there. Only when
+ /// the id holding the samples is NOT a live registered strap is the #1193 split the explanation left.
+ ///
+ /// That correction then over-corrected. "So this is expected" assumes a night is worn on ONE strap, and
+ /// a reporter wearing a 4.0 and a 5.0 together hit the case it denies: the active strap banked nothing
+ /// because its handshake never completed (#1635), while the other strap's rows made the line declare
+ /// the silence normal. Nothing available here can tell the two apart — the wearer knows which straps
+ /// were on the wrist and this function cannot — so it states the fork instead of picking a side, and
+ /// names the sync as what to check in the half where something IS wrong.
+ static func orphanedSamplesLine(activeId: String, othersWithSamples: [(String, Int)],
+ otherLiveStrapIds: Set = []) -> String {
if othersWithSamples.isEmpty {
return "(no raw biometric samples under '\(activeId)' for this night — expected on a freshly "
+ "re-added strap; reconnect + let a history sync run, then re-export)"
}
+ let ownedByAnotherStrap = othersWithSamples.filter { otherLiveStrapIds.contains($0.0) }
+ if !ownedByAnotherStrap.isEmpty {
+ let who = ownedByAnotherStrap.sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.0 < $1.0 }
+ .map { "'\($0.0)' (\($0.1) rows)" }
+ .joined(separator: ", ")
+ return "(no raw biometric samples under the ACTIVE id '\(activeId)' for this night — they are "
+ + "under \(who), another registered strap. If you wore THAT strap this night, this is expected "
+ + "and the dayOwner line for this date names the owner. If you wore BOTH, the active strap "
+ + "banked nothing for this night and its sync is what to check, not this line.)"
+ }
// Tie-break on id: Kotlin's sortedByDescending is stable but Swift's `sorted` is NOT, so equal
// counts could otherwise order differently on the two platforms and the twin lines would diverge.
// The tie-break itself compares Unicode canonical order here and UTF-16 code units in Kotlin; those
diff --git a/Strand/System/NotificationPresenter.swift b/Strand/System/NotificationPresenter.swift
index 8d534c51ea..9c5448eda0 100644
--- a/Strand/System/NotificationPresenter.swift
+++ b/Strand/System/NotificationPresenter.swift
@@ -17,6 +17,12 @@ final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
private override init() { super.init() }
+ /// K5: wired by the app root (`StrandApp` on macOS, `StrandiOSApp` on iOS) at launch to route a
+ /// tapped scheduled morning-brief notification to the Coach screen via `NavRouter.openCoach()`. nil
+ /// is a safe no-op (the tap is simply not routed) rather than a crash if this ever fires before the
+ /// root has wired it.
+ var onCoachBriefTapped: (() -> Void)?
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@@ -24,4 +30,18 @@ final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
) {
completionHandler([.banner, .sound, .list])
}
+
+ /// Handle a tap on a delivered notification. Only the scheduled morning-brief category (K5) routes
+ /// anywhere; every other notification (wind-down, smart-alarm, battery/illness) just opens the app
+ /// to wherever it was, matching the pre-K5 behaviour.
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void
+ ) {
+ if response.notification.request.content.categoryIdentifier == CoachBriefScheduler.notificationCategoryId {
+ onCoachBriefTapped?()
+ }
+ completionHandler()
+ }
}
diff --git a/Strand/System/RescoreBackgroundScheduler.swift b/Strand/System/RescoreBackgroundScheduler.swift
index 590cbda71d..8933eede0e 100644
--- a/Strand/System/RescoreBackgroundScheduler.swift
+++ b/Strand/System/RescoreBackgroundScheduler.swift
@@ -36,8 +36,18 @@ enum RescoreBackgroundScheduler {
/// Seconds the last COMPLETED pass took. Only ever written by a pass that reached the end.
static let lastPassSecondsKey = "noop.rescoreLastPassSeconds"
+ /// Identifies the MOST RECENT debt, so a pass can tell its own from someone else's (#1681).
+ ///
+ /// A token rather than a counter, deliberately. A counter needs read-modify-write, and two triggers
+ /// marking a debt at the same moment could both read N and both write N+1 — losing an increment, and
+ /// with it exactly the debt this is meant to protect. A fresh token is a single write: concurrent
+ /// marks each produce a distinct one, the last wins, and it cannot equal any pass's captured token.
+ static let owedTokenKey = "noop.rescoreOwedToken"
+
static var isRescoreOwed: Bool { UserDefaults.standard.bool(forKey: owedKey) }
+ static var currentOwedToken: String? { UserDefaults.standard.string(forKey: owedTokenKey) }
+
static var lastCompletedPassSeconds: Double? {
guard UserDefaults.standard.object(forKey: lastPassSecondsKey) != nil else { return nil }
let value = UserDefaults.standard.double(forKey: lastPassSecondsKey)
@@ -47,17 +57,53 @@ enum RescoreBackgroundScheduler {
/// Mark a re-score as owed. Called by `IntelligenceEngine` once a pass is past every gate and is
/// definitely about to work — so that a kill leaves the debt behind — and by the deferral path, where
/// no pass is attempted at all but the work is just as outstanding.
- static func markRescoreOwed() {
+ /// Returns the token stamped on this debt. A pass keeps it and hands it back at completion; every
+ /// other caller (the deferral path) can ignore it, since it is not the one that will settle up.
+ @discardableResult
+ static func markRescoreOwed() -> String {
+ let token = UUID().uuidString
UserDefaults.standard.set(true, forKey: owedKey)
+ UserDefaults.standard.set(token, forKey: owedTokenKey)
+ return token
+ }
+
+ /// May a pass holding [capturedToken] settle the debt?
+ ///
+ /// Only if nothing newer was recorded while it ran. The bug in #1681 is that this question was never
+ /// asked: completion cleared one global boolean unconditionally, so a trigger firing mid-pass — for
+ /// data that arrived AFTER the pass had already read its inputs — had its debt erased before the
+ /// correction it was recorded for ever ran. The scoring loop starts at today, so the night most
+ /// likely to still be syncing is the first thing read and the likeliest to be caught mid-write.
+ ///
+ /// A pass with NO token never settles. That errs toward one extra pass, which costs battery; the
+ /// other direction costs a night's scores until something unrelated happens to re-score it, which is
+ /// the failure being fixed.
+ ///
+ /// Pure, so the rule is pinned without UserDefaults or a background task.
+ static func maySettleDebt(capturedToken: String?, currentToken: String?) -> Bool {
+ guard let capturedToken, !capturedToken.isEmpty else { return false }
+ return capturedToken == currentToken
}
/// Settle the debt at the end of a completed pass, beside the watermark advance. A pass that is
/// killed never reaches this, which is what leaves the mark set for the next launch to find.
- static func markRescoreCompleted(seconds: Double) {
- UserDefaults.standard.set(false, forKey: owedKey)
+ /// [owedToken] is the token this pass received from its own `markRescoreOwed()`. The debt is settled
+ /// only if it is still the current one — see `maySettleDebt`. The pass duration is recorded either
+ /// way: it is telemetry about THIS pass, and true regardless of whose debt is now outstanding.
+ /// Returns whether the debt was actually settled, so the caller can SAY when it was not. Declining
+ /// is the interesting outcome and it must not be silent: #1538 cost three nights because the log
+ /// recorded that scoring had not happened without ever recording why, and a pass that completes
+ /// while leaving the mark set looks identical to one that cleared it unless something says so.
+ @discardableResult
+ static func markRescoreCompleted(seconds: Double, owedToken: String?) -> Bool {
+ let settled = maySettleDebt(capturedToken: owedToken, currentToken: currentOwedToken)
+ if settled {
+ UserDefaults.standard.set(false, forKey: owedKey)
+ }
if seconds.isFinite, seconds > 0 {
UserDefaults.standard.set(seconds, forKey: lastPassSecondsKey)
}
+ return settled
}
/// Whether the app is somewhere a long pass might not survive. Always false on macOS — see the type doc.
diff --git a/Strand/System/TestBundleAssembler.swift b/Strand/System/TestBundleAssembler.swift
index 14fb2c8beb..e6b80f5dee 100644
--- a/Strand/System/TestBundleAssembler.swift
+++ b/Strand/System/TestBundleAssembler.swift
@@ -228,7 +228,7 @@ enum TestBundleAssembler {
profileStartedAt: started,
questionnaire: TestCentre.answers(profile),
build: buildProvenance(),
- storage: storage ?? TestBundleMeta.Storage(dbBytes: 0, rows: [:], rawCaptureBytes: 0),
+ storage: storage ?? TestBundleMeta.Storage(dbBytes: 0, rows: [:], rawCaptureBytes: 0, rowBytes: [:]),
redaction: redactionVersion,
truncated: truncated,
captureCheck: checks)
diff --git a/Strand/System/TestBundleMeta.swift b/Strand/System/TestBundleMeta.swift
index fa9f792083..cb4884a7cc 100644
--- a/Strand/System/TestBundleMeta.swift
+++ b/Strand/System/TestBundleMeta.swift
@@ -32,9 +32,14 @@ struct TestBundleMeta: Codable {
/// db_bytes plus per-table row counts plus the raw-capture footprint (#590 asked us to surface this).
struct Storage: Codable {
let dbBytes: Int; let rows: [String: Int]; let rawCaptureBytes: Int
+ /// #1911: estimated payload bytes per table, keyed as `rows` is. Rows alone cannot say which table
+ /// holds a large store — `ppgWaveformSample`'s rows carry a BLOB, so it is exactly where a row
+ /// model misleads. A sampled payload estimate with no index or page overhead, so read it as a
+ /// lower bound to compare against `db_bytes`, never as a second file size.
+ let rowBytes: [String: Int]
enum CodingKeys: String, CodingKey {
case rows
- case dbBytes = "db_bytes", rawCaptureBytes = "raw_capture_bytes"
+ case dbBytes = "db_bytes", rawCaptureBytes = "raw_capture_bytes", rowBytes = "row_bytes"
}
}
diff --git a/Strand/System/TestCentreReport.swift b/Strand/System/TestCentreReport.swift
index 7f684203ce..97493255ee 100644
--- a/Strand/System/TestCentreReport.swift
+++ b/Strand/System/TestCentreReport.swift
@@ -78,20 +78,31 @@ final class TestCentreReport: ObservableObject {
}
}
var rows: [String: Int] = [:]
+ var rowBytes: [String: Int] = [:]
var rawBytes = 0
if let store = await repo?.storeHandle() {
- if let c = try? await store.storageStats_rowCountsForTest() {
- rows = ["hr": c.hr, "rr": c.rr, "events": c.events, "battery": c.battery,
- "spo2": c.spo2, "skinTemp": c.skinTemp, "resp": c.resp, "gravity": c.gravity]
- }
- if let steps = try? await store.stepCountForTest() { rows["steps"] = steps }
- rawBytes = (try? await store.storageStats().rawBytes) ?? 0
+ // #1911: every accumulating table, not the nine this used to name. The old shape read eight
+ // counts out of a test helper and bolted `steps` on beside them, which silently omitted
+ // ppgHr, sleepState, ppgWaveform and v18Aux — the four Android already reports and flags as
+ // "can each be large". A footprint that leaves out the only blob table cannot attribute the
+ // bytes it exists to explain, which is exactly what #1911 needs answered.
+ if let counts = try? await store.storageRowCounts() { rows = counts }
+ // #1911: bytes beside the counts. Rows alone cannot say which table holds a large store -
+ // ppgWaveformSample's rows carry a BLOB, so it is precisely where a row model misleads, and
+ // precisely the table this question is usually about. Estimated (sampled payload, no index or
+ // page overhead), so it is a lower bound to compare against db_bytes, not a second file size.
+ if let bytes = try? await store.storageByteEstimates(rowCounts: rows) { rowBytes = bytes }
+ // `rawOutboxStats`, not `storageStats`: the latter counts all thirteen decoded tables to
+ // return a total this probe does not use, and those are full scans on exactly the stores
+ // this report is pulled from.
+ rawBytes = (try? await store.rawOutboxStats().bytes) ?? 0
}
if let url = live.puffinCaptureURL {
rawBytes += (try? fm.attributesOfItem(atPath: url.path))?[.size] as? Int ?? 0
}
guard dbBytes > 0 || !rows.isEmpty || rawBytes > 0 else { return nil }
- return TestBundleMeta.Storage(dbBytes: dbBytes, rows: rows, rawCaptureBytes: rawBytes)
+ return TestBundleMeta.Storage(dbBytes: dbBytes, rows: rows, rawCaptureBytes: rawBytes,
+ rowBytes: rowBytes)
}
/// The user read the report and confirmed: clear the gate and run the shipped share + deep-link flow.
diff --git a/Strand/System/UpdateAvailability.swift b/Strand/System/UpdateAvailability.swift
new file mode 100644
index 0000000000..5ab0815221
--- /dev/null
+++ b/Strand/System/UpdateAvailability.swift
@@ -0,0 +1,183 @@
+import Foundation
+import WhoopProtocol
+
+/// When may NOOP look for a newer release on its own, and when is that worth telling the user about?
+///
+/// The manual "Check for updates" button has always been deliberately user-initiated (see `UpdateChecker`).
+/// This adds the automatic half, for the reason #1659 asks for it: on iOS there is NO auto-update to fall
+/// back on. A sideloaded app cannot install or re-sign an `.ipa` — only AltStore/SideStore can, and only
+/// for people who added the source. Everyone else has no way to learn a release happened without going
+/// looking, which is exactly the state the issue describes.
+///
+/// So the most the app can honestly do is NOTICE and SAY SO. It posts into the Updates inbox the app
+/// already has, which means no new surface and no interruption: the bell picks up an unread row, the same
+/// way What's New does after an update.
+///
+/// Kotlin twin: `com.noop.update.UpdateAvailability`.
+enum UpdateAvailability {
+
+ /// ON by default (maintainer's call, #1659).
+ ///
+ /// It shipped off first, on the reading that "fully offline, on-device, no telemetry" made an
+ /// unasked-for launch request wrong on principle. The counter-argument won: a sideloaded app has no
+ /// store to update it, so a user who never finds this setting is a user who silently runs an old
+ /// build — which is the whole problem the issue reported. A default nobody discovers is not a
+ /// compromise, it is the feature not existing.
+ ///
+ /// What keeps it honest is what the request IS: one read of a public version number, once a day,
+ /// after onboarding and the Terms gate. Nothing about the user is sent, nothing is uploaded, and no
+ /// data leaves the device — the offline promise is about the user's HEALTH DATA, and that is
+ /// untouched. Anyone who disagrees turns it off in Settings, and it then makes no request at all.
+ static let defaultEnabled = true
+
+ /// Once a day. The thing being watched moves on the order of days-to-weeks, so anything tighter spends
+ /// requests (and a little battery) to learn nothing.
+ static let checkInterval: TimeInterval = 24 * 60 * 60
+
+ /// May a background check run now?
+ ///
+ /// [lastCheckedAt] is epoch seconds, 0 meaning "never checked". A never-checked install is due
+ /// immediately, so turning the toggle on gives an answer during that session rather than tomorrow.
+ ///
+ /// A clock that has moved BACKWARDS (timezone edit, NTP correction, a restored backup) would otherwise
+ /// park the next check arbitrarily far in the future — `now < lastCheckedAt` is treated as due, which
+ /// self-heals on the next write.
+ static func shouldCheckNow(enabled: Bool,
+ lastCheckedAt: TimeInterval,
+ now: TimeInterval,
+ interval: TimeInterval = checkInterval) -> Bool {
+ guard enabled else { return false }
+ if lastCheckedAt <= 0 { return true }
+ if now < lastCheckedAt { return true } // clock went backwards — don't strand the check
+ return now - lastCheckedAt >= interval
+ }
+
+ /// Is this result worth a row in the inbox?
+ ///
+ /// ONCE PER VERSION. [lastPostedVersion] is persisted, so a user who reads the row and does nothing —
+ /// which on iOS may be entirely reasonable, since acting means AltStore or a re-sign — is not told
+ /// again tomorrow, and the day after. An app that nags about something the user cannot act on quickly
+ /// teaches people to ignore the bell, which costs more than the feature is worth.
+ static func shouldPost(latest: String, current: String, lastPostedVersion: String?) -> Bool {
+ guard VersionCheck.isNewer(latest, than: current) else { return false }
+ return latest != lastPostedVersion
+ }
+
+ /// Has the install CAUGHT UP with a version we previously announced?
+ ///
+ /// The row says "NOOP 10.7.0 is available". Once the user actually installs 10.7.0 that sentence is
+ /// false, and it sits in the inbox directly beside the What's New row for the same version — an app
+ /// telling you to get something you already have. Nothing else prunes it, because the row carries no
+ /// version field of its own; the persisted `lastPostedVersion` is what makes this answerable without
+ /// parsing the title back out.
+ ///
+ /// Must be evaluated even when the toggle is OFF: someone can post a row, switch the check off, then
+ /// update — and the stale row would otherwise outlive the feature that made it.
+ static func shouldPruneAnnouncement(lastPostedVersion: String?, current: String) -> Bool {
+ guard let posted = lastPostedVersion, !posted.isEmpty else { return false }
+ return !VersionCheck.isNewer(posted, than: current)
+ }
+
+ /// Assemble the row's body from ALREADY-LOCALIZED fragments.
+ ///
+ /// The copy itself deliberately does NOT live here any more. A first cut built these strings as a
+ /// byte-identical twin, the way the BLE diagnostic lines are — but that is the wrong half of the
+ /// parity contract. Analytics and stored data are byte-identical; user-facing copy is LOCALIZED per
+ /// platform, and the sibling What's New row on iOS already is. An unlocalized row would have sat in a
+ /// translated inbox in English, and no i18n gate would have caught it: they scan SwiftUI `Text` and
+ /// `@Composable` literals, not model-layer strings.
+ ///
+ /// What stays twinned is the ASSEMBLY — order, the single space, the blank line before notes, and the
+ /// trimming — which is real logic and is what the tests pin. The fragments are separate sentences, so
+ /// joining them cannot produce the word-order damage that concatenating clauses would.
+ static func composeMessage(body: String, sideload: String?, notes: String) -> String {
+ var s = body
+ if let sideload, !sideload.isEmpty { s += " " + sideload }
+ let trimmed = notes.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { s += "\n\n" + trimmed }
+ return s
+ }
+}
+
+// MARK: - The automatic check
+
+/// Drives the #1659 automatic check: decide, fetch, post, remember. Kept out of `UpdateAvailability` so
+/// that stays pure — every rule this obeys is tested there without a network or a clock.
+///
+/// Kotlin twin: `com.noop.update.UpdateWatch`.
+enum UpdateWatch {
+
+ enum Keys {
+ /// On by default; see `UpdateAvailability.defaultEnabled` for why, and what the request is.
+ static let enabled = "updates.autoCheck"
+ static let lastCheckedAt = "updates.lastCheckedAt"
+ static let lastPostedVersion = "updates.lastPostedVersion"
+ }
+
+ /// The real marketing version straight from the bundle (CFBundleShortVersionString, set from
+ /// project.yml MARKETING_VERSION), so a check can never compare against a hand-edited constant that
+ /// has gone stale — which is exactly the bug that once told v7 users they were behind. Hoisted here
+ /// from SettingsView, which had it private, so the button and the automatic check cannot disagree
+ /// about what version is installed.
+ static var installedVersion: String {
+ (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? AppChangelog.currentVersion
+ }
+
+ /// Re-entrancy guard. `runIfDue` is called from `.onAppear`, which is not guaranteed to fire once,
+ /// and the day's slot is stamped INSIDE the task — so two appearances in quick succession could both
+ /// pass the due check before either had written anything, and fire two requests for one answer.
+ @MainActor private static var inFlight = false
+
+ static var isEnabled: Bool {
+ UserDefaults.standard.object(forKey: Keys.enabled) as? Bool ?? UpdateAvailability.defaultEnabled
+ }
+
+ /// Run a check if one is due, and post to the inbox if the result is worth saying.
+ ///
+ /// Every early return is silent BY DESIGN — this runs at launch, and an install with the toggle off
+ /// (the default) must produce no line, no request and no trace. The manual button remains the loud
+ /// path: it reports "couldn't check", because there a human is waiting on an answer.
+ @MainActor
+ static func runIfDue(currentVersion: String, sideloadHint: Bool, now: Date = Date()) {
+ let d = UserDefaults.standard
+ // Runs before every guard below, including the toggle: a stale announcement must not outlive the
+ // feature that posted it (see `shouldPruneAnnouncement`).
+ if UpdateAvailability.shouldPruneAnnouncement(
+ lastPostedVersion: d.string(forKey: Keys.lastPostedVersion), current: currentVersion) {
+ for item in UpdateStore.shared.items where item.kind == .newVersion {
+ UpdateStore.shared.remove(item.id)
+ }
+ d.removeObject(forKey: Keys.lastPostedVersion)
+ }
+ guard !inFlight else { return }
+ guard UpdateAvailability.shouldCheckNow(enabled: isEnabled,
+ lastCheckedAt: d.double(forKey: Keys.lastCheckedAt),
+ now: now.timeIntervalSince1970) else { return }
+ inFlight = true
+ Task {
+ defer { inFlight = false }
+ // Stamped BEFORE the result is examined, and deliberately: a failed or unparseable read must
+ // still consume the day's slot. Stamping only on success would retry every launch for as long
+ // as GitHub is unreachable, which is the one shape a background check must never take.
+ d.set(now.timeIntervalSince1970, forKey: Keys.lastCheckedAt)
+ guard let release = await UpdateChecker.fetchLatest() else { return }
+ guard UpdateAvailability.shouldPost(latest: release.version,
+ current: currentVersion,
+ lastPostedVersion: d.string(forKey: Keys.lastPostedVersion))
+ else { return }
+ d.set(release.version, forKey: Keys.lastPostedVersion)
+ // Localized HERE, at the platform edge, so the row reads in the user's language like the
+ // What's New row beside it. `composeMessage` only assembles what it is handed.
+ let body = String(localized: "You're on \(currentVersion). Open Settings and use Check for updates to see what's new and download \(release.version).")
+ let sideload = sideloadHint
+ ? String(localized: "AltStore or SideStore can install it for you automatically if you added NOOP's source; a direct .ipa still has to be signed on your device.")
+ : nil
+ UpdateStore.shared.post(UpdateItem(
+ kind: .newVersion,
+ title: String(localized: "NOOP \(release.version) is available"),
+ message: UpdateAvailability.composeMessage(body: body,
+ sideload: sideload,
+ notes: release.notes)))
+ }
+ }
+}
diff --git a/Strand/System/UpdateChecker.swift b/Strand/System/UpdateChecker.swift
index c43de30b16..0449ca9cf8 100644
--- a/Strand/System/UpdateChecker.swift
+++ b/Strand/System/UpdateChecker.swift
@@ -1,10 +1,20 @@
import Foundation
import WhoopProtocol
-/// User-initiated "Check for updates": one call to the project's PUBLIC releases API (GitHub),
-/// made ONLY when the user taps the button. No background polling and no auto-update — it just reads the latest version
-/// number and compares it to the installed one; nothing about the user is sent. (Uses the
-/// network-client entitlement, which is otherwise only for the opt-in, off-by-default AI Coach.)
+/// "Check for updates": one call to the project's PUBLIC releases API (GitHub), reading the latest
+/// version number and comparing it to the installed one. Nothing about the user is sent, and it never
+/// installs anything — on iOS no API permits that for a sideloaded app.
+///
+/// TWO callers share this, and the distinction matters to anyone auditing what the app does on its own:
+/// - the Settings button, which runs only when tapped;
+/// - `UpdateWatch`, the #1659 daily check, which runs at most once a day after onboarding and the Terms
+/// gate. It is ON by default and switching it off in Settings stops the request entirely.
+///
+/// This header previously said the read happened ONLY on a tap. That stopped being true the moment the
+/// automatic caller was added, and a false claim here is worse than none: it is the file someone opens to
+/// answer "does this app poll?". Documented for real in docs/PRIVACY_SECURITY.md §1.1c.
+///
+/// (Uses the network-client entitlement, which is otherwise only for the opt-in, off-by-default AI Coach.)
@MainActor
final class UpdateChecker: ObservableObject {
@@ -20,30 +30,43 @@ final class UpdateChecker: ObservableObject {
private static let endpoint = URL(string: "https://api.github.com/repos/ryanbr/noop/releases/latest")!
+ /// One release read. Shared by the button and the automatic check (#1659) so there is exactly one
+ /// copy of the endpoint, the headers and the parsing — a second copy is how the two would drift into
+ /// disagreeing about what "latest" means.
+ struct Release: Equatable {
+ let version: String
+ let url: URL
+ let notes: String
+ }
+
+ static func fetchLatest() async -> Release? {
+ do {
+ var req = URLRequest(url: Self.endpoint, timeoutInterval: 12)
+ req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ guard (resp as? HTTPURLResponse)?.statusCode == 200,
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let tag = json["tag_name"] as? String,
+ let urlString = json["html_url"] as? String,
+ let url = URL(string: urlString) else { return nil }
+ let latest = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag
+ return Release(version: latest, url: url, notes: cleanNotes(json["body"] as? String ?? ""))
+ } catch {
+ return nil
+ }
+ }
+
func check(currentVersion: String) {
guard state != .checking else { return }
state = .checking
Task {
- do {
- var req = URLRequest(url: Self.endpoint, timeoutInterval: 12)
- req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
- let (data, resp) = try await URLSession.shared.data(for: req)
- guard (resp as? HTTPURLResponse)?.statusCode == 200,
- let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
- let tag = json["tag_name"] as? String,
- let urlString = json["html_url"] as? String,
- let url = URL(string: urlString) else {
- state = .failed
- return
- }
- let latest = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag
- let notes = Self.cleanNotes(json["body"] as? String ?? "")
- state = VersionCheck.isNewer(latest, than: currentVersion)
- ? .available(version: latest, url: url, notes: notes)
- : .upToDate(version: latest)
- } catch {
+ guard let release = await Self.fetchLatest() else {
state = .failed
+ return
}
+ state = VersionCheck.isNewer(release.version, than: currentVersion)
+ ? .available(version: release.version, url: release.url, notes: release.notes)
+ : .upToDate(version: release.version)
}
}
diff --git a/Strand/System/WindDownNudge.swift b/Strand/System/WindDownNudge.swift
index 24eba99849..1002a5e9ad 100644
--- a/Strand/System/WindDownNudge.swift
+++ b/Strand/System/WindDownNudge.swift
@@ -95,6 +95,27 @@ enum WindDownNudge {
return ((raw % day) + day) % day
}
+ /// The day shift for a given weekday's nudge — 0 if the nudge lands on the same day as the wake,
+ /// -1 if it wraps to the previous evening. The minute-of-day wrap in `nudgeMinuteOfDay` silently
+ /// loses which day the nudge belongs to; this companion carries that information so the scheduler
+ /// can pin the trigger to the nudge's day, not the wake's. (#1863)
+ ///
+ /// `raw` is always in [-780, 1139] given the clamped inputs (wake ≤ 1439, sleepNeed ≥ 300, lead ≤ 120),
+ /// so the shift is always 0 or -1 — but the formula is general floor-division for safety.
+ static func nudgeDayShift(forWeekday weekday: Int) -> Int {
+ let raw = wakeMinutes(forWeekday: weekday) - sleepNeedMinutes - leadMinutes
+ let day = 24 * 60
+ // Floor division (Swift's `/` truncates toward zero, so adjust for negatives).
+ return raw < 0 ? -(((-raw - 1) / day) + 1) : raw / day
+ }
+
+ /// Shift a Calendar weekday (1=Sun…7=Sat) by a day count, wrapping through the week end. (#1863)
+ /// `shiftedWeekday(weekday: 1, by: -1)` → 7 (Sunday's evening-before nudge fires on Saturday).
+ static func shiftedWeekday(weekday: Int, by shift: Int) -> Int {
+ let zeroBased = weekday - 1 + shift
+ return ((zeroBased % 7) + 7) % 7 + 1
+ }
+
// MARK: - Public API
/// The result of enabling the nudge — lets the UI react instead of silently persisting an "on" toggle
@@ -190,11 +211,18 @@ enum WindDownNudge {
if hasPerDayOverrides {
for weekday in 1...7 {
let minute = nudgeMinuteOfDay(forWeekday: weekday)
+ // #1863 — an early wake (e.g. 03:30) wraps the nudge minute to the previous evening
+ // (19:00). The minute wrap alone loses which DAY that evening belongs to, so the trigger
+ // was pinned to the wake's own weekday — firing 8+ hours AFTER the wake it precedes.
+ // Derive the day shift and pin the trigger to the nudge's day, not the wake's.
+ let shift = nudgeDayShift(forWeekday: weekday)
var comps = DateComponents()
- comps.weekday = weekday // Calendar weekday 1=Sun…7=Sat → fires weekly on that day
+ comps.weekday = shiftedWeekday(weekday: weekday, by: shift) // the nudge's day
comps.hour = minute / 60
comps.minute = minute % 60
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
+ // The request id stays keyed to the wake's weekday so toggling/clearing overrides
+ // always matches the ids `perDayRequestIds` enumerates.
center.add(UNNotificationRequest(identifier: "\(requestId)-wd\(weekday)",
content: content, trigger: trigger))
}
diff --git a/StrandTests/AICoachSleepContextTests.swift b/StrandTests/AICoachSleepContextTests.swift
new file mode 100644
index 0000000000..47197c8c8c
--- /dev/null
+++ b/StrandTests/AICoachSleepContextTests.swift
@@ -0,0 +1,52 @@
+import XCTest
+@testable import Strand
+import WhoopStore
+
+/// The sleep detail the coach could not see (#1817), and the normalisation that stops it reading
+/// nonsense. Twin of the Kotlin `AiCoachContextTest` cases; the formatters are pure, so this runs
+/// headlessly with no repo reads.
+@MainActor
+final class AICoachSleepContextTests: XCTestCase {
+
+ private func engine() -> AICoachEngine {
+ AICoachEngine(repo: Repository(deviceId: "test-aicoach-sleep"))
+ }
+
+ private func day(deep: Double? = nil, rem: Double? = nil,
+ light: Double? = nil, efficiency: Double? = nil) -> DailyMetric {
+ DailyMetric(day: "2026-06-01", totalSleepMin: 450, efficiency: efficiency,
+ deepMin: deep, remMin: rem, lightMin: light, disturbances: nil,
+ restingHr: 52, avgHrv: 65, recovery: 67, strain: 12.3, exerciseCount: nil)
+ }
+
+ /// `rest 7.5h` used to be every word the coach got about a night.
+ func testStagesReachTheContextLine() {
+ let line = engine().dayLine(day(deep: 84, rem: 114, light: 252, efficiency: 0.94))
+ XCTAssertTrue(line.contains("deep 1.4h"), line)
+ XCTAssertTrue(line.contains("REM 1.9h"), line)
+ XCTAssertTrue(line.contains("light 4.2h"), line)
+ XCTAssertTrue(line.contains("eff 94%"), line)
+ }
+
+ /// A night with no staging says so rather than the field vanishing, so the model cannot read a
+ /// missing stage as a zero.
+ func testUnstagedNightReportsDashesNotSilence() {
+ let line = engine().dayLine(day())
+ XCTAssertTrue(line.contains("deep —"), line)
+ XCTAssertTrue(line.contains("REM —"), line)
+ XCTAssertTrue(line.contains("light —"), line)
+ XCTAssertTrue(line.contains("eff —"), line)
+ }
+
+ /// Efficiency arrives as a PERCENTAGE on some import paths, which `SleepView` and `StagesCard`
+ /// each guard against inline. Without the same guard a bare `* 100` sends "eff 9400%".
+ func testEfficiencyStoredAsPercentageIsNotMultipliedAgain() {
+ let e = engine()
+ XCTAssertEqual(e.efficiencyPercentOrDash(0.94), "94%")
+ XCTAssertEqual(e.efficiencyPercentOrDash(94.0), "94%")
+ XCTAssertEqual(e.efficiencyPercentOrDash(nil), "—")
+ XCTAssertEqual(e.efficiencyPercentOrDash(0), "—")
+ // Above a fraction but below the percentage split: not a value this can honestly render.
+ XCTAssertEqual(e.efficiencyPercentOrDash(1.2), "—")
+ }
+}
diff --git a/StrandTests/ActiveWorkoutClockTests.swift b/StrandTests/ActiveWorkoutClockTests.swift
new file mode 100644
index 0000000000..69f2e51351
--- /dev/null
+++ b/StrandTests/ActiveWorkoutClockTests.swift
@@ -0,0 +1,69 @@
+import XCTest
+@testable import Strand
+
+/// Pins the pause-aware workout clock every live surface now shares.
+///
+/// #1533 added Pause/Resume but only the full-screen live view's timer subtracted the paused time; the
+/// Today indicator and the Live card kept reading `now - start`, so pausing froze one clock and left two
+/// counting. These cases are the twin of Kotlin `ActiveWorkoutClockTest` — same scenarios, same expected
+/// seconds — so the two platforms cannot drift on what "elapsed" means for a paused session.
+final class ActiveWorkoutClockTests: XCTestCase {
+
+ private let start = Date(timeIntervalSince1970: 1_000)
+
+ private func elapsed(pausedAt: TimeInterval? = nil, pausedDuration: TimeInterval = 0,
+ now: TimeInterval) -> Int {
+ Int(ActiveWorkoutClock.activeElapsed(
+ start: start,
+ pausedAt: pausedAt.map { start.addingTimeInterval($0) },
+ pausedDuration: pausedDuration,
+ now: start.addingTimeInterval(now)))
+ }
+
+ func testActiveElapsedSubtractsCompletedAndOpenPauses() {
+ XCTAssertEqual(elapsed(now: 65), 65) // never paused
+ XCTAssertEqual(elapsed(pausedDuration: 20, now: 65), 45) // one finished pause
+ XCTAssertEqual(elapsed(pausedAt: 30, now: 65), 30) // paused at 30s, still paused
+ XCTAssertEqual(elapsed(pausedAt: 50, pausedDuration: 10, now: 65), 40) // both
+ }
+
+ /// The whole point of the fix: while paused the number must not move, however much wall time passes.
+ /// This is what the two card surfaces got wrong, and a clock that merely subtracts a CONSTANT would
+ /// still tick — so the open pause has to grow with `now`.
+ func testClockIsFrozenWhilePaused() {
+ XCTAssertEqual(elapsed(pausedAt: 30, now: 65), 30)
+ XCTAssertEqual(elapsed(pausedAt: 30, now: 99), 30)
+ XCTAssertEqual(elapsed(pausedAt: 30, now: 4_000), 30)
+ }
+
+ func testNeverCountsBackwards() {
+ XCTAssertEqual(elapsed(now: -5), 0) // clock skew
+ XCTAssertEqual(elapsed(pausedDuration: 70, now: 65), 0) // paused longer than the session
+ }
+
+ /// The Live card carried its own `%d:%02d` formatter with no hour roll-over, so a 90-minute session
+ /// read "90:00" there and "1:30:00" on the Today indicator beside it. One formatter now.
+ func testClockFormatRollsOverAtAnHour() {
+ XCTAssertEqual(ActiveWorkoutClock.clock(0), "0:00")
+ XCTAssertEqual(ActiveWorkoutClock.clock(65), "1:05")
+ XCTAssertEqual(ActiveWorkoutClock.clock(3_600), "1:00:00")
+ XCTAssertEqual(ActiveWorkoutClock.clock(5_400), "1:30:00")
+ XCTAssertEqual(ActiveWorkoutClock.clock(2 * 3_600 + 5 * 60 + 9), "2:05:09")
+ XCTAssertEqual(ActiveWorkoutClock.clock(-5), "0:00")
+ }
+
+ /// The Today indicator renders from a value type, so the pause state has to reach IT — being correct
+ /// on `AppModel` is not enough, and that is exactly how the card kept counting.
+ func testIndicatorModelCarriesPauseStateAndFreezes() {
+ var workout = AppModel.ActiveWorkout(start: start)
+ workout.pausedAt = start.addingTimeInterval(30)
+ let model = ActiveWorkoutIndicatorModel.make(from: workout)
+ XCTAssertEqual(model?.isPaused, true)
+ XCTAssertEqual(model?.pausedAt, workout.pausedAt)
+ XCTAssertEqual(
+ ActiveWorkoutIndicatorModel.elapsed(since: start, pausedAt: model?.pausedAt,
+ pausedDuration: model?.pausedDuration ?? 0,
+ now: start.addingTimeInterval(600)),
+ "0:30")
+ }
+}
diff --git a/StrandTests/AlarmReadbackDecodeTests.swift b/StrandTests/AlarmReadbackDecodeTests.swift
index 2e4092d94f..e21b8eda4c 100644
--- a/StrandTests/AlarmReadbackDecodeTests.swift
+++ b/StrandTests/AlarmReadbackDecodeTests.swift
@@ -46,6 +46,40 @@ final class AlarmReadbackDecodeTests: XCTestCase {
XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_750_990_944)
}
+ /// The armed GET readback, captured verbatim on fw 41.17.6.0 (2026-08-26, #34/#1706): 11 bytes,
+ /// `[form 0x01][stored 0x01][u32 LE epoch][00 00][04 00 20]`, epoch at OFFSET 2. The SET-mirror
+ /// read at offset 1 decodes this very payload to 2_389_889_025 — 2045-09-24, inside the
+ /// plausibility window — so the gate cannot catch the misread and only this fixture pins the
+ /// offset. (The arm that produced it sent epoch 1_787_720_400; the strap stored it exactly.)
+ func testGetReadbackPayload_decodesEpochAtOffsetTwo() {
+ let frame = responseFrame(payload: [0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20])
+ XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_787_720_400)
+ }
+
+ /// All three arm/readback pairs from the same capture set (2026-08-26..28): each readback carries
+ /// exactly the epoch the arm sent, at offset 2. Three independent values rule out coincidence —
+ /// under the offset-1 misread each decodes to the value the app actually logged (2389889025 /
+ /// 2412007425 / 2434125825: the sent epoch's low three bytes shifted up a byte, plus the flag).
+ func testGetReadback_allThreeCaptures_decodeTheSentEpoch() {
+ let captures: [([UInt8], UInt32)] = [
+ ([0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_720_400),
+ ([0x01, 0x01, 0x50, 0xC4, 0x8F, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_806_800),
+ ([0x01, 0x01, 0xD0, 0x15, 0x91, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20], 1_787_893_200),
+ ]
+ for (payload, sentEpoch) in captures {
+ XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: responseFrame(payload: payload)), sentEpoch)
+ }
+ }
+
+ /// An 11-byte readback whose epoch field is implausible falls to the raw-hex line, NOT to the
+ /// mirror offsets — those are known-wrong for this shape (they fold the stored flag into the
+ /// epoch's low byte), so trying them here could resurrect the misread this shape exists to kill.
+ func testGetReadback_implausibleEpoch_fallsToRawHexNotMirrorRead() {
+ let frame = responseFrame(payload: [0x01, 0x01, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x20])
+ XCTAssertNil(FrameRouter.armedAlarmEpoch(in: frame))
+ XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame))
+ }
+
/// A result-style single byte (e.g. an UNSUPPORTED echo) must not decode; the router falls back to
/// the raw-hex line.
func testShortGarbagePayload_decodesNil() {
@@ -84,6 +118,13 @@ final class AlarmReadbackDecodeTests: XCTestCase {
XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame))
}
+ /// An armed 11-byte GET readback is NOT "no alarm" — same mutual exclusion, readback shape.
+ func testArmedGetReadback_isNotReportedAsNoAlarm() {
+ let frame = responseFrame(payload: [0x01, 0x01, 0xD0, 0x72, 0x8E, 0x6A, 0x00, 0x00, 0x04, 0x00, 0x20])
+ XCTAssertEqual(FrameRouter.armedAlarmEpoch(in: frame), 1_787_720_400)
+ XCTAssertFalse(FrameRouter.readbackReportsNoAlarm(in: frame))
+ }
+
/// A short result-style payload (0x03) is neither an armed epoch NOR the epoch-0 sentinel — it's
/// genuinely unparseable, so it still falls through to the raw-hex "unrecognised" branch.
func testShortGarbage_isNotReportedAsNoAlarm() {
diff --git a/StrandTests/AppTargetFrameCorpusIntegrityTests.swift b/StrandTests/AppTargetFrameCorpusIntegrityTests.swift
new file mode 100644
index 0000000000..1472af4b67
--- /dev/null
+++ b/StrandTests/AppTargetFrameCorpusIntegrityTests.swift
@@ -0,0 +1,74 @@
+import XCTest
+@testable import Strand
+import WhoopProtocol
+
+/// Scenario "Strukturelle Mindest- und Genaulänge je Gerätefamilie / Echte aufgezeichnete Rahmen
+/// bleiben gültig", for the app target's own frame corpus.
+///
+/// The app-target corpus is not a resource directory — `StrandTests/Resources/` holds only a
+/// capability table with no frame bytes. The real frames live as hex LITERALS inside three test files:
+/// `RawHistoryArchiveReplayTests` and `BackfillerSessionTallyTests` (three real WHOOP 4.0 v25 records,
+/// 84 bytes each) and `BatteryResultProvenanceDumpTests` (a real GET_BATTERY_LEVEL COMMAND_RESPONSE).
+/// They are repeated here on purpose: those files assert what the frames DECODE to, this one asserts
+/// that the tightened envelope rules still admit them at all. If a future change makes the verifier
+/// stricter than the hardware, this is the test that says so — the other three would fail with a
+/// decode error that reads like a decoder bug.
+///
+/// A tightened verifier that rejected any of these would mean real straps losing real records, which
+/// is a strictly worse outcome than the forgery the change prevents.
+final class AppTargetFrameCorpusIntegrityTests: XCTestCase {
+
+ private func bytes(_ s: String) -> [UInt8] {
+ var out = [UInt8](); out.reserveCapacity(s.count / 2); var i = s.startIndex
+ while i < s.endIndex { let j = s.index(i, offsetBy: 2)
+ out.append(UInt8(s[i.. (hr: Int, rr: Int, events: Int, battery: Int,
+ spo2: Int, skinTemp: Int, resp: Int, gravity: Int) {
+ (streams.hr.count, streams.rr.count, 0, 0,
+ streams.spo2.count, streams.skinTemp.count, streams.resp.count, streams.gravity.count)
+ }
+ func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws {}
+ func setCursor(_ name: String, _ value: Int) async throws {}
+ func cursor(_ name: String) async throws -> Int? { nil }
+ }
+
+ private func le32(_ v: UInt32) -> [UInt8] {
+ [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF), UInt8((v >> 16) & 0xFF), UInt8((v >> 24) & 0xFF)]
+ }
+
+ /// A protocol-correct WHOOP 4.0 HISTORY_END (METADATA, meta_type 2) carrying unix + trim cursor.
+ private func historyEndFrame(unix: UInt32 = 1_700_000_000, trim: UInt32 = 70_476) -> [UInt8] {
+ frameFromPayload(le32(unix) + [0, 0] + le32(0) + le32(trim), type: 49, seq: 0, cmd: 2)
+ }
+
+ /// Collects every trim acknowledgement the Backfiller issues. `@MainActor` because `Backfiller` is.
+ @MainActor private func makeBackfiller(_ acks: @escaping (UInt32) -> Void) -> Backfiller {
+ Backfiller(store: NoopStore(), deviceId: "test", ackTrim: { trim, _ in acks(trim) })
+ }
+
+ // MARK: - control: the intact frame does acknowledge
+
+ @MainActor func testAnIntactHistoryEndAcknowledgesTheTrim() async {
+ var acked: [UInt32] = []
+ let backfiller = makeBackfiller { acked.append($0) }
+ backfiller.begin(family: .whoop4)
+ await backfiller.ingest(historyEndFrame())
+ XCTAssertEqual(acked, [70_476], "control: a real HISTORY_END must still advance the offload")
+ }
+
+ // MARK: - a forged or damaged HISTORY_END acknowledges nothing
+
+ @MainActor func testAHistoryEndWithABrokenHeaderChecksumAcknowledgesNothing() async {
+ var frame = historyEndFrame()
+ frame[3] ^= 0xFF // CRC-8 over the length field only
+ let parsed = parseFrame(frame, family: .whoop4)
+ XCTAssertEqual(parsed.crcOK, true, "precondition: the payload CRC32 still verifies")
+ XCTAssertEqual(parsed.parsed["meta_type"], .string("HISTORY_END(2)"),
+ "precondition: it still decodes as a history end")
+ XCTAssertEqual(parsed.parsed["trim_cursor"]?.intValue, 70_476,
+ "precondition: the trim cursor it carries is readable")
+
+ var acked: [UInt32] = []
+ let backfiller = makeBackfiller { acked.append($0) }
+ backfiller.begin(family: .whoop4)
+ await backfiller.ingest(frame)
+ XCTAssertTrue(acked.isEmpty,
+ "acking on this frame frees records the strap would then delete, got \(acked)")
+ }
+
+ @MainActor func testAHistoryEndWithADeclaredLengthBelowTheMinimumAcknowledgesNothing() async {
+ var frame = historyEndFrame()
+ frame[1] = 6; frame[2] = 0 // declared 6 → total 10, under the 11-byte floor
+ frame[3] = crc8(frame, 1, 3) // with a CORRECT checksum for that word
+ XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .belowMinimumLength)
+
+ var acked: [UInt32] = []
+ let backfiller = makeBackfiller { acked.append($0) }
+ backfiller.begin(family: .whoop4)
+ await backfiller.ingest(frame)
+ XCTAssertTrue(acked.isEmpty, "got \(acked)")
+ }
+
+ @MainActor func testAHistoryEndWithATruncatedTrailerAcknowledgesNothing() async {
+ let frame = Array(historyEndFrame().dropLast(2))
+ let parsed = parseFrame(frame, family: .whoop4)
+ XCTAssertEqual(parsed.rejectReason, .lengthMismatch)
+ XCTAssertEqual(parsed.parsed["meta_type"], .string("HISTORY_END(2)"),
+ "precondition: without the gate this frame WOULD read as a history end")
+
+ var acked: [UInt32] = []
+ let backfiller = makeBackfiller { acked.append($0) }
+ backfiller.begin(family: .whoop4)
+ await backfiller.ingest(frame)
+ XCTAssertTrue(acked.isEmpty, "got \(acked)")
+ }
+
+ @MainActor func testAHistoryEndWithTrailingBytesAcknowledgesNothing() async {
+ let frame = historyEndFrame() + [0x00, 0x00]
+ XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .lengthMismatch)
+
+ var acked: [UInt32] = []
+ let backfiller = makeBackfiller { acked.append($0) }
+ backfiller.begin(family: .whoop4)
+ await backfiller.ingest(frame)
+ XCTAssertTrue(acked.isEmpty, "got \(acked)")
+ }
+
+ /// The other half of the forgery: HISTORY_COMPLETE ends the whole offload. A damaged one must not
+ /// close a session that is still mid-flight.
+ @MainActor func testADamagedHistoryCompleteDoesNotEndTheOffload() async {
+ var complete = frameFromPayload([], type: 49, seq: 0, cmd: 3)
+ complete[3] ^= 0xFF
+ XCTAssertEqual(parseFrame(complete, family: .whoop4).parsed["meta_type"],
+ .string("HISTORY_COMPLETE(3)"), "precondition: it still decodes as complete")
+
+ let backfiller = makeBackfiller { _ in }
+ backfiller.begin(family: .whoop4)
+ XCTAssertTrue(backfiller.isBackfilling, "precondition: the session is open")
+ await backfiller.ingest(complete)
+ XCTAssertTrue(backfiller.isBackfilling, "a damaged HISTORY_COMPLETE must not close the session")
+
+ // Control: the same frame INTACT does close it, so the gate is what made the difference.
+ let control = makeBackfiller { _ in }
+ control.begin(family: .whoop4)
+ await control.ingest(frameFromPayload([], type: 49, seq: 0, cmd: 3))
+ XCTAssertFalse(control.isBackfilling)
+ }
+
+ // MARK: - the acknowledgement block is EXEMPT from the payload bound (D7, decision 4)
+
+ /// The eight bytes the trim acknowledgement mirrors back to the strap reach INTO the CRC32 trailer
+ /// by design: on the real 25-byte HISTORY_END the trailer starts at 21 and the block runs 17…25.
+ /// It is an opaque echo, not a decoded field, so the payload bound that now clamps every named
+ /// field must not touch it.
+ ///
+ /// This is the CALLER-side half of that exemption. The protocol package pins the slice itself; what
+ /// is pinned here is that the app's own reader still hands eight bytes to the ack. Clamped to the
+ /// trailer it would yield four, and the strap would be acknowledged with a block it never sent —
+ /// either it refuses the acknowledgement and the offload stops, or it trims on an altered block.
+ /// Both outcomes are the permanent data loss this whole change exists to prevent, arriving through
+ /// the fix rather than the bug.
+ @MainActor func testTheWhoop4AcknowledgementBlockIsEightBytesAndReachesIntoTheTrailer() {
+ let frame = historyEndFrame()
+ XCTAssertEqual(frame.count, 25, "precondition: the real HISTORY_END size the exemption is about")
+ let declared = Int(frame[1]) | (Int(frame[2]) << 8)
+ XCTAssertEqual(declared, 21, "precondition: the CRC32 trailer starts at 21")
+
+ let endData = Backfiller.endData(from: frame, family: .whoop4)
+ XCTAssertEqual(endData?.count, 8, "eight bytes, not the four a trailer clamp would leave")
+ XCTAssertEqual(endData, Array(frame[17..<25]), "…and exactly the bytes at 17…25, unaltered")
+ XCTAssertNotEqual(endData, Array(frame[17..<21]) + [0, 0, 0, 0],
+ "a clamped-then-padded block is not the same echo")
+ }
+
+ /// The 5/MG twin of the same slice (frame[21…29]), so a later tidy-up cannot narrow one family
+ /// while the other keeps working.
+ @MainActor func testTheWhoop5AcknowledgementBlockIsAlsoEightBytes() {
+ let frame = puffinCommandFrame(cmd: 2, seq: 0,
+ payload: [UInt8](repeating: 7, count: 20), type: 49)
+ XCTAssertGreaterThanOrEqual(frame.count, 29, "precondition: long enough to hold the block")
+ let endData = Backfiller.endData(from: frame, family: .whoop5)
+ XCTAssertEqual(endData?.count, 8)
+ XCTAssertEqual(endData, Array(frame[21..<29]))
+ }
+
+ /// The guard the exemption does keep: a frame too short to hold the block yields nil rather than a
+ /// short read. Not-enough-bytes is a different answer from four-bytes-because-we-clamped.
+ @MainActor func testAFrameTooShortForTheBlockYieldsNilRatherThanAShortRead() {
+ XCTAssertNil(Backfiller.endData(from: frameFromPayload([], type: 49, seq: 0, cmd: 2),
+ family: .whoop4))
+ }
+}
diff --git a/StrandTests/BackfillerSessionTallyTests.swift b/StrandTests/BackfillerSessionTallyTests.swift
index 3ad3f0e91d..227157849b 100644
--- a/StrandTests/BackfillerSessionTallyTests.swift
+++ b/StrandTests/BackfillerSessionTallyTests.swift
@@ -285,4 +285,99 @@ final class BackfillerSessionTallyTests: XCTestCase {
"a truly-empty no-cursor session must still warn the strap has no banked history")
XCTAssertTrue(joined.contains("fully charge it"))
}
+
+ // MARK: - #1683: the stale counterpart to futureRtcLine
+
+ /// A strap that stopped banking weeks ago and one that is caught up produced the SAME "banked no
+ /// sensor history" line, so neither the user nor a triager could tell them apart. That is why #1541
+ /// stayed open and unactionable.
+ func testACaughtUpOrBrieflyIdleStrapIsNotStale() {
+ let now = 1_700_000_000
+ XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: nil, wallNowUnix: now))
+ XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: 0, wallNowUnix: now))
+ XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: now, wallNowUnix: now))
+ XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: now - 86_400, wallNowUnix: now),
+ "one night off-wrist is ordinary and must stay silent")
+ }
+
+ func testTwoDaysIsTheBoundaryAndQualifies() {
+ let now = 1_700_000_000
+ XCTAssertTrue(Backfiller.isStaleNewestRecord(newestUnix: now - 2 * 86_400, wallNowUnix: now))
+ }
+
+ /// A future-dated record belongs to `futureRtcLine`; this rule must not also claim it.
+ func testAFutureDatedRecordIsNotStale() {
+ let now = 1_700_000_000
+ XCTAssertFalse(Backfiller.isStaleNewestRecord(newestUnix: now + 86_400, wallNowUnix: now))
+ }
+
+ /// The numbers from the #1683 capture: newest stored record 1785692420 against a wall clock of
+ /// 1787820941. The user was told only "banked no sensor history"; this says three weeks.
+ func testStaleRecordLineReportsTheRealCaptureAsTwentyFourDays() {
+ let line = Backfiller.staleRecordLine(newestUnix: 1_785_692_420, wallNowUnix: 1_787_820_941)
+ XCTAssertTrue(line.contains("about 24 day(s) old"), line)
+ XCTAssertTrue(line.contains("stopped saving history"), line)
+ // The part the old advice omitted: charging alone has already been retried every connect.
+ XCTAssertTrue(line.contains("re-sends the clock on every connect"), line)
+ // The test that tells the user whether NOOP is even involved.
+ XCTAssertTrue(line.contains("official WHOOP app"), line)
+ XCTAssertFalse(line.contains("\u{2014}"))
+ }
+
+ /// States the fact, never the diagnosis: a drawered strap shows the same number innocently.
+ func testStaleRecordLineDoesNotAssertACorruptClock() {
+ let line = Backfiller.staleRecordLine(newestUnix: 1_700_000_000 - 20 * 86_400,
+ wallNowUnix: 1_700_000_000)
+ XCTAssertFalse(line.contains("corrupt"), line)
+ XCTAssertTrue(line.contains("If you have worn it"), line)
+ }
+
+ /// The banner is what the user READS; the log line needs a capture export. The standing banner
+ /// omitted the age entirely and PROMISED that charging "should" work - advice NOOP has effectively
+ /// retried on every connect for weeks, since it re-sends SET_CLOCK each time.
+ func testStaleRecordBannerDatesTheSilenceAndPromisesNothing() {
+ let line = Backfiller.staleRecordBanner(newestUnix: 1_785_692_420, wallNowUnix: 1_787_820_941)
+ XCTAssertTrue(line.contains("about 24 day(s) old"), line)
+ XCTAssertTrue(line.contains("If you have been wearing it"), line)
+ XCTAssertTrue(line.contains("official WHOOP app"), line)
+ XCTAssertFalse(line.contains("should start banking again"), line)
+ XCTAssertFalse(line.contains("\u{2014}"))
+ }
+
+ // ---- #1754: two distinct empty-offload banners ------------------------------------------
+
+ /// The no-flash-cursor banner (trim=0xFFFFFFFF) names the clock/charge cause — the existing copy,
+ /// now a named constant so the caller's branch reads as a choice between two states.
+ func testNoFlashCursorBannerNamesClockAndCharge() {
+ let line = Backfiller.noFlashCursorBanner
+ XCTAssertTrue(line.contains("no stored history to hand over"), line)
+ XCTAssertTrue(line.contains("clock has lost sync"), line)
+ XCTAssertTrue(line.contains("Fully charge it to 100%"), line)
+ XCTAssertFalse(line.contains("sensor front-end"), line)
+ }
+
+ /// The no-sensor-records banner (valid trim, advancing write pointer, zero rows) does NOT name
+ /// the clock — it points at the sensor front-end or power state, and asks for a strap log rather
+ /// than promising that charging will fix it.
+ func testNoSensorRecordsBannerDoesNotBlameTheClock() {
+ let line = Backfiller.noSensorRecordsBanner
+ XCTAssertTrue(line.contains("no sensor records"), line)
+ XCTAssertTrue(line.contains("flash cursor is valid and advancing"), line)
+ XCTAssertTrue(line.contains("not a clock problem"), line)
+ XCTAssertTrue(line.contains("sensor front-end or power"), line)
+ XCTAssertFalse(line.contains("clock has lost sync"), line)
+ XCTAssertFalse(line.contains("Fully charge it to 100%"), line)
+ }
+
+ /// The two banners must be distinct strings — a caller choosing between them must not get the
+ /// same copy for both states.
+ func testTheTwoBannersAreDistinct() {
+ XCTAssertNotEqual(Backfiller.noFlashCursorBanner, Backfiller.noSensorRecordsBanner)
+ }
+
+ /// No em-dash in either banner (project rule).
+ func testNoEmDashInEitherBanner() {
+ XCTAssertFalse(Backfiller.noFlashCursorBanner.contains("\u{2014}"))
+ XCTAssertFalse(Backfiller.noSensorRecordsBanner.contains("\u{2014}"))
+ }
}
diff --git a/StrandTests/BackupSyncRoundTripTests.swift b/StrandTests/BackupSyncRoundTripTests.swift
index b28e47b336..6e976fc2e9 100644
--- a/StrandTests/BackupSyncRoundTripTests.swift
+++ b/StrandTests/BackupSyncRoundTripTests.swift
@@ -75,6 +75,7 @@ final class BackupSyncRoundTripTests: XCTestCase {
"profile.heightCm": 168.0,
"profile.hrMax": 191,
"units.system": "imperial",
+ "units.distance": "metric",
])
// Restore into a throwaway DB path AND a suite-scoped defaults (never the runner's real domain).
@@ -93,6 +94,7 @@ final class BackupSyncRoundTripTests: XCTestCase {
XCTAssertEqual(defaults.object(forKey: "profile.hrMaxOverride") as? Int, 191,
"Canonical profile.hrMax lands on ProfileStore's profile.hrMaxOverride key")
XCTAssertEqual(defaults.string(forKey: "units.system"), "imperial")
+ XCTAssertEqual(defaults.string(forKey: "units.distance"), "metric")
}
func testLegacySingleEntryZipStillRestoresAndAppliesNoSettings() throws {
diff --git a/StrandTests/BackupSyncTests.swift b/StrandTests/BackupSyncTests.swift
index c958433e06..ba1c8df69e 100644
--- a/StrandTests/BackupSyncTests.swift
+++ b/StrandTests/BackupSyncTests.swift
@@ -130,4 +130,45 @@ final class BackupSyncTests: XCTestCase {
let out = BackupSync.restorablesNewestFirst([z, a]) { _ in sameMs }
XCTAssertEqual(out.map(\.name), [a, z])
}
+
+ // MARK: - Folder identity (#52): "Saving to:" has to say WHICH folder
+
+ func testTrailSlicesAfterTheICloudSyncRoot() {
+ // macOS shape: merely filtering out the tilde component would leave "Mobile Documents › NOOP".
+ XCTAssertEqual(
+ FolderBackup.folderTrail(path: "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP"),
+ "NOOP")
+ // iOS shape, two components deep.
+ XCTAssertEqual(
+ FolderBackup.folderTrail(
+ path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Backups/NOOP"),
+ "Backups › NOOP")
+ // A per-app ubiquity container is a sync root too.
+ XCTAssertEqual(
+ FolderBackup.folderTrail(path: "/var/mobile/Library/Mobile Documents/iCloud~com~foo~bar/Documents/B"),
+ "Documents › B")
+ }
+
+ func testTrailIsEmptyAtTheSyncRootItself() {
+ // Nothing below the root: `folderLabel` must fall back to naming the place, not emit a dangling "›".
+ XCTAssertEqual(
+ FolderBackup.folderTrail(path: "/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs"),
+ "")
+ }
+
+ func testTrailKeepsTwoComponentsOutsideICloud() {
+ // The #52 internal fallback and an external cloud folder both stay identifiable.
+ XCTAssertEqual(
+ FolderBackup.folderTrail(path: "/var/mobile/Containers/Data/Application/9F3A-UUID/Documents/Backups"),
+ "Documents › Backups")
+ XCTAssertEqual(
+ FolderBackup.folderTrail(path: "/private/var/mobile/Containers/Shared/AppGroup/U/Dropbox/NoopBackups"),
+ "Dropbox › NoopBackups")
+ }
+
+ func testICloudPathRuleMatchesTheDiagnosticsSignal() {
+ // One definition: the screen and `restoreListHealth`'s dump must never disagree.
+ XCTAssertTrue(FolderBackup.isICloudPath("/Users/x/Library/Mobile Documents/com~apple~CloudDocs/NOOP"))
+ XCTAssertFalse(FolderBackup.isICloudPath("/var/mobile/Containers/Data/Application/U/Documents/Backups"))
+ }
}
diff --git a/StrandTests/BondRefusalGiveUpTests.swift b/StrandTests/BondRefusalGiveUpTests.swift
index 2f4e16da7e..184d4aca3e 100644
--- a/StrandTests/BondRefusalGiveUpTests.swift
+++ b/StrandTests/BondRefusalGiveUpTests.swift
@@ -68,4 +68,25 @@ final class BondRefusalGiveUpTests: XCTestCase {
XCTAssertTrue(hint.contains("Forget This Device"))
XCTAssertFalse(hint.contains("\u{2014}"))
}
+
+ /// A per-refusal threshold moves the crossing without duplicating it. Kotlin twin:
+ /// `BondRefusalGiveUpTest."a lower threshold latches sooner, and the latch still reports once"`.
+ func testPerRefusalThresholdLatchesSoonerAndStillReportsOnce() {
+ var g = BondRefusalGiveUp() // pause threshold 5
+ XCTAssertFalse(g.recordRefusal(threshold: 3))
+ XCTAssertFalse(g.recordRefusal(threshold: 3))
+ XCTAssertTrue(g.recordRefusal(threshold: 3), "crossed at 3, not 5")
+ XCTAssertTrue(g.gaveUp)
+ XCTAssertFalse(g.recordRefusal(threshold: 3), "the latch reports exactly once")
+ XCTAssertEqual(4, g.refusals)
+ }
+
+ /// Omitting it keeps the constructed threshold, so every existing caller is unchanged.
+ func testOmittingTheThresholdKeepsTheConstructedOne() {
+ var g = BondRefusalGiveUp()
+ XCTAssertEqual(5, g.giveUpThreshold)
+ for _ in 1...4 { XCTAssertFalse(g.recordRefusal()) }
+ XCTAssertTrue(g.recordRefusal())
+ }
+
}
diff --git a/StrandTests/ClientHelloOutcomeTests.swift b/StrandTests/ClientHelloOutcomeTests.swift
index fd3459edc4..215b42e2a2 100644
--- a/StrandTests/ClientHelloOutcomeTests.swift
+++ b/StrandTests/ClientHelloOutcomeTests.swift
@@ -68,5 +68,39 @@ final class ClientHelloOutcomeTests: XCTestCase {
XCTAssertFalse(ClientHelloOutcome.isAck(
isHelloChar: true, helloOutstanding: true, alreadyBonded: false, isWhoop5: false))
}
+
+ // MARK: - #1883: Apple bond timing tell
+
+ /// A completion faster than one connection interval did not come from the strap — that is the
+ /// signature of #1635's false bond. The line must name it as UNVERIFIED and reference both issues.
+ func testSuspiciouslyFastCompletionFlagsUnverifiedBond() {
+ let line = ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 5)
+ XCTAssertNotNil(line)
+ XCTAssertTrue(line!.contains("acked after 5ms"), line!)
+ XCTAssertTrue(line!.contains("under one BLE connection interval"), line!)
+ XCTAssertTrue(line!.contains("UNVERIFIED"), line!)
+ XCTAssertTrue(line!.contains("#1883"), line!)
+ XCTAssertTrue(line!.contains("#1635"), line!)
+ }
+
+ /// A completion at or above the threshold is plausible and returns nil — no diagnostic line.
+ func testPlausibleCompletionReturnsNil() {
+ XCTAssertNil(ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 8))
+ XCTAssertNil(ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 120))
+ XCTAssertNil(ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 3150))
+ }
+
+ /// The threshold matches the Android twin (MIN_PLAUSIBLE_ATT_ROUND_TRIP_MS = 8).
+ func testThresholdMatchesAndroidTwin() {
+ XCTAssertEqual(ClientHelloOutcome.minPlausibleAttRoundTripMs, 8)
+ }
+
+ /// A 0ms completion is the most suspicious — it cannot be a round trip at all.
+ func testZeroMsCompletionFlagsUnverifiedBond() {
+ let line = ClientHelloOutcome.unverifiedBondTimingLine(elapsedMs: 0)
+ XCTAssertNotNil(line)
+ XCTAssertTrue(line!.contains("acked after 0ms"), line!)
+ XCTAssertTrue(line!.contains("UNVERIFIED"), line!)
+ }
}
diff --git a/StrandTests/CoachConversationDayTests.swift b/StrandTests/CoachConversationDayTests.swift
new file mode 100644
index 0000000000..f0fb455e8d
--- /dev/null
+++ b/StrandTests/CoachConversationDayTests.swift
@@ -0,0 +1,120 @@
+import XCTest
+@testable import Strand
+
+/// #1542 twin of `CoachConversationDayTest`. The Coach kept answering today's question inside
+/// yesterday's conversation: `messages` outlives a night, so the assistant's own earlier turns state
+/// yesterday's figures and the model stays consistent with them. The data was never stale —
+/// `buildFullContext()` re-reads on every send — which is why it reads as "the coach only talks about my
+/// imported data" rather than as stale numbers.
+final class CoachConversationDayTests: XCTestCase {
+
+ /// A fixed UTC calendar, so no case depends on the machine's zone or clock.
+ private var utc: Calendar = {
+ var c = Calendar(identifier: .gregorian)
+ c.timeZone = TimeZone(secondsFromGMT: 0)!
+ return c
+ }()
+
+ private func day(_ y: Int, _ m: Int, _ d: Int) -> Int {
+ var comps = DateComponents()
+ comps.year = y; comps.month = m; comps.day = d
+ let date = utc.date(from: comps)!
+ return AICoachEngine.localEpochDay(date, calendar: utc)
+ }
+
+ func testFreshSessionIsNeverStale() {
+ // Nothing sent yet this session: there is no transcript to retire.
+ XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: nil,
+ todayEpochDay: day(2026, 8, 22)))
+ }
+
+ func testSameDayKeepsTheConversation() {
+ let today = day(2026, 8, 22)
+ XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: today, todayEpochDay: today))
+ }
+
+ func testOvernightRetiresTheConversation() {
+ // The reported case: last turn yesterday evening, next question this morning.
+ XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 8, 21),
+ todayEpochDay: day(2026, 8, 21)))
+ XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 8, 21),
+ todayEpochDay: day(2026, 8, 22)))
+ }
+
+ func testLongGapRetiresTheConversation() {
+ XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 6, 11),
+ todayEpochDay: day(2026, 8, 22)))
+ }
+
+ /// Flying west, a timezone change or an NTP correction can move the local day BACKWARDS
+ /// mid-conversation. That must not wipe a transcript the user is in the middle of, which is why the
+ /// rule is strictly forward (`>`) and not `!=`. Relaxing it to `!=` fails this case and the year
+ /// boundary below — the two that give the test teeth.
+ func testClockMovingBackwardsKeepsTheConversation() {
+ XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 8, 22),
+ todayEpochDay: day(2026, 8, 21)))
+ }
+
+ func testYearBoundaryIsJustAnotherDay() {
+ XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: day(2025, 12, 31),
+ todayEpochDay: day(2026, 1, 1)))
+ XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: day(2026, 1, 1),
+ todayEpochDay: day(2025, 12, 31)))
+ }
+
+ /// Swift-only, because Swift-only needs it: Kotlin gets its epoch day from `LocalDate`, while this
+ /// side computes one. Counted with calendar day arithmetic rather than by dividing an interval by
+ /// 86,400 — a day is not always 86,400 seconds. Across a DST spring-forward the count must still
+ /// advance by exactly one, or a transcript would survive a night twice a year.
+ func testLocalEpochDayAdvancesByOneAcrossADSTBoundary() {
+ var london = Calendar(identifier: .gregorian)
+ london.timeZone = TimeZone(identifier: "Europe/London")!
+ var before = DateComponents()
+ before.year = 2026; before.month = 3; before.day = 28; before.hour = 12
+ var after = DateComponents()
+ after.year = 2026; after.month = 3; after.day = 29; after.hour = 12 // clocks go forward
+ let d0 = AICoachEngine.localEpochDay(london.date(from: before)!, calendar: london)
+ let d1 = AICoachEngine.localEpochDay(london.date(from: after)!, calendar: london)
+ XCTAssertEqual(d1 - d0, 1, "a 23-hour day must still be one day")
+ XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: d0, todayEpochDay: d1))
+ }
+
+ /// The symmetric DST case, and the one that actually threatens this implementation. `localEpochDay`
+ /// counts whole days from the epoch INSTANT to a local midnight, so the count truncates — and on a
+ /// 25-hour autumn day the extra hour is exactly the kind of slack that could make two local midnights
+ /// land in the same whole-day bucket. If that happened, a transcript would survive a night once a
+ /// year and the bug this whole change fixes would come back, seasonally.
+ ///
+ /// Spring-forward was tested first because it is the obvious one. It is not the dangerous one.
+ func testLocalEpochDayAdvancesByOneAcrossTheAutumnFallBack() {
+ var london = Calendar(identifier: .gregorian)
+ london.timeZone = TimeZone(identifier: "Europe/London")!
+ var before = DateComponents()
+ before.year = 2026; before.month = 10; before.day = 24; before.hour = 12
+ var after = DateComponents()
+ after.year = 2026; after.month = 10; after.day = 25; after.hour = 12 // clocks go back
+ let d0 = AICoachEngine.localEpochDay(london.date(from: before)!, calendar: london)
+ let d1 = AICoachEngine.localEpochDay(london.date(from: after)!, calendar: london)
+ XCTAssertEqual(d1 - d0, 1, "a 25-hour day must still be one day")
+ XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: d0, todayEpochDay: d1))
+ }
+
+ /// A zone far west of UTC, where the epoch instant itself falls on the PREVIOUS local day. The origin
+ /// this counts from is not local midnight, so the arithmetic has to stay monotonic anyway — ordering
+ /// is the only property the rule depends on.
+ func testLocalEpochDayIsMonotonicWestOfUTC() {
+ var la = Calendar(identifier: .gregorian)
+ la.timeZone = TimeZone(identifier: "America/Los_Angeles")!
+ var comps = DateComponents()
+ comps.year = 2026; comps.month = 8; comps.day = 21; comps.hour = 23; comps.minute = 30
+ let lateEvening = la.date(from: comps)!
+ let nextMorning = lateEvening.addingTimeInterval(3_600) // 00:30 the next local day
+ let d0 = AICoachEngine.localEpochDay(lateEvening, calendar: la)
+ let d1 = AICoachEngine.localEpochDay(nextMorning, calendar: la)
+ XCTAssertEqual(d1 - d0, 1, "crossing local midnight is one day, whatever the UTC offset")
+ XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: d0, todayEpochDay: d1))
+ // And the hour before midnight is emphatically the same day.
+ let earlier = lateEvening.addingTimeInterval(-3_600)
+ XCTAssertEqual(AICoachEngine.localEpochDay(earlier, calendar: la), d0)
+ }
+}
diff --git a/StrandTests/CollectorStandardHRContactTests.swift b/StrandTests/CollectorStandardHRContactTests.swift
new file mode 100644
index 0000000000..fe68fd33d3
--- /dev/null
+++ b/StrandTests/CollectorStandardHRContactTests.swift
@@ -0,0 +1,110 @@
+import XCTest
+import WhoopProtocol
+import WhoopStore
+@testable import Strand
+
+@MainActor
+final class CollectorStandardHRContactTests: XCTestCase {
+ private final class CaptureStore: StoreWriting {
+ enum Failure: Error { case requested }
+
+ var inserted: [Streams] = []
+ var failNextInsert = false
+
+ func insert(_ streams: Streams, deviceId: String) async throws
+ -> (hr: Int, rr: Int, events: Int, battery: Int,
+ spo2: Int, skinTemp: Int, resp: Int, gravity: Int) {
+ if failNextInsert {
+ failNextInsert = false
+ throw Failure.requested
+ }
+ inserted.append(streams)
+ return (streams.hr.count, streams.rr.count, streams.events.count,
+ streams.battery.count, streams.spo2.count, streams.skinTemp.count,
+ streams.resp.count, streams.gravity.count)
+ }
+
+ func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws {}
+ }
+
+ /// Repinned for change-only contact recording: only TRANSITIONS record, so thirty identical
+ /// readings now produce one event rather than thirty. The invariant is unchanged — contact events
+ /// alone can drive the auto-flush threshold, without a single HR or R-R row — but it has to be
+ /// measured with readings that actually change, which is what a strap going on and off produces.
+ func testContactOnlyBatchAutoFlushesAtThreshold() async {
+ let store = CaptureStore()
+ let collector = Collector(store: store, deviceId: "whoop-5")
+
+ var contact = StandardHRContact.supportedNotDetected
+ for ts in 1_750_000_000..<1_750_000_030 {
+ collector.ingestStandardHR(hr: 0, rr: [], contact: contact, at: ts)
+ contact = contact == .supportedNotDetected ? .supportedDetected : .supportedNotDetected
+ }
+ for _ in 0..<10 where store.inserted.isEmpty { await Task.yield() }
+
+ XCTAssertEqual(store.inserted.count, 1)
+ guard let inserted = store.inserted.first else { return }
+ XCTAssertTrue(inserted.hr.isEmpty)
+ XCTAssertTrue(inserted.rr.isEmpty)
+ XCTAssertEqual(inserted.events.count, 30)
+ }
+
+ /// The other half of the same rule, and the one that made the repin necessary: a run of identical
+ /// readings records ONCE. At the ~1 Hz the standard profile actually arrives at, the old
+ /// per-reading write was ~86,400 rows a day per device saying the same thing.
+ func testARunOfIdenticalReadingsRecordsOnlyTheFirst() async {
+ let store = CaptureStore()
+ let collector = Collector(store: store, deviceId: "whoop-5")
+
+ for ts in 1_750_000_000..<1_750_000_030 {
+ collector.ingestStandardHR(hr: 0, rr: [], contact: .supportedNotDetected, at: ts)
+ }
+ // One event never reaches the 30-item auto-flush threshold, so nothing has been written yet.
+ XCTAssertTrue(store.inserted.isEmpty)
+
+ await collector.flushStandardHR()
+ XCTAssertEqual(store.inserted.count, 1)
+ XCTAssertEqual(store.inserted.first?.events.count, 1)
+ XCTAssertEqual(store.inserted.first?.events.first?.ts, 1_750_000_000)
+ }
+
+ func testWhoopStandardHRCollectorPersistsParsedContact() async {
+ let store = CaptureStore()
+ let collector = Collector(store: store, deviceId: "whoop-5")
+
+ collector.ingestStandardHR(
+ hr: 72, rr: [1_000], contact: .supportedNotDetected, at: 1_750_000_000
+ )
+ await collector.flushStandardHR()
+
+ XCTAssertEqual(store.inserted, [
+ StandardHRMapping.samples(
+ fromHR: 72,
+ rr: [1_000],
+ contact: .supportedNotDetected,
+ at: 1_750_000_000
+ )
+ ])
+ }
+
+ func testWhoopStandardHRCollectorRebuffersContactAfterInsertFailure() async {
+ let store = CaptureStore()
+ store.failNextInsert = true
+ let collector = Collector(store: store, deviceId: "whoop-5")
+
+ collector.ingestStandardHR(
+ hr: 73, rr: [], contact: .supportedDetected, at: 1_750_000_001
+ )
+ await collector.flushStandardHR()
+ XCTAssertTrue(store.inserted.isEmpty)
+
+ await collector.flushStandardHR()
+ XCTAssertEqual(store.inserted.first?.events, [
+ WhoopEvent(
+ ts: 1_750_000_001,
+ kind: StandardHRMapping.contactEventKind,
+ payload: ["contact": .string("supported_detected")]
+ )
+ ])
+ }
+}
diff --git a/StrandTests/DailySkinTempAbsoluteCarryTests.swift b/StrandTests/DailySkinTempAbsoluteCarryTests.swift
index 7c2c3f415c..6ac4faf08d 100644
--- a/StrandTests/DailySkinTempAbsoluteCarryTests.swift
+++ b/StrandTests/DailySkinTempAbsoluteCarryTests.swift
@@ -14,14 +14,25 @@ import WhoopStore
/// automatically, so there is no equivalent seam to drop a column at).
final class DailySkinTempAbsoluteCarryTests: XCTestCase {
+ /// `DailyMetric`'s initializer defaults only from `spo2Pct` onward — its first twelve parameters are
+ /// required. Every case here needs two or three of them, so they go through this rather than each
+ /// spelling out ten `nil`s, and the sleep-carry cases below cannot drift into compact calls that do
+ /// not compile. (They did: these tests shipped uncompilable in #1879, and `StrandTests` runs only in
+ /// the on-demand app-build, so nothing said so.)
private func row(day: String = "2026-08-25",
+ totalSleepMin: Double? = nil,
+ deepMin: Double? = nil,
+ remMin: Double? = nil,
+ lightMin: Double? = nil,
+ restingHr: Int? = nil,
skinTempC: Double? = nil,
skinTempDevC: Double? = nil,
- avgHrv: Double? = nil) -> DailyMetric {
- DailyMetric(day: day, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil,
- lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: avgHrv,
- recovery: nil, strain: nil, exerciseCount: nil,
- skinTempDevC: skinTempDevC, skinTempC: skinTempC)
+ avgHrv: Double? = nil,
+ sleepHrOnly: Bool? = nil) -> DailyMetric {
+ DailyMetric(day: day, totalSleepMin: totalSleepMin, efficiency: nil, deepMin: deepMin,
+ remMin: remMin, lightMin: lightMin, disturbances: nil, restingHr: restingHr,
+ avgHrv: avgHrv, recovery: nil, strain: nil, exerciseCount: nil,
+ skinTempDevC: skinTempDevC, skinTempC: skinTempC, sleepHrOnly: sleepHrOnly)
}
/// An imported winner carries no absolute, so the computed filler's must survive the coalesce.
@@ -72,4 +83,55 @@ final class DailySkinTempAbsoluteCarryTests: XCTestCase {
XCTAssertEqual(try XCTUnwrap(scored.skinTempDevC), 0.52, accuracy: 0.001)
XCTAssertEqual(try XCTUnwrap(scored.recovery), 0.71, accuracy: 0.001)
}
+
+ // MARK: - The same four seams, for the HR-only staging flag (#1801)
+
+ /// `sleepHrOnly` rides the identical rebuild path as the absolute above, and the struct has no
+ /// `copy()` — every seam respells the field list, so a new column is dropped by omission rather than
+ /// by error. These four are that column's version of the tests above.
+
+ func testScoringKeepsTheStagingFlag() throws {
+ let scored = row(skinTempC: 34.6).with(recovery: 0.71, skinTempDevC: 0.52, skinTempC: 34.6)
+ XCTAssertNil(scored.sleepHrOnly, "a row that never carried the flag must not invent one")
+ let hrOnly = row(day: "2026-09-03", sleepHrOnly: true)
+ .with(recovery: 0.71, skinTempDevC: nil, skinTempC: nil)
+ XCTAssertEqual(hrOnly.sleepHrOnly, true, "scoring must not discard how the night was staged")
+ }
+
+ func testASleepEditKeepsTheStagingFlag() {
+ let edited = row(day: "2026-09-03", sleepHrOnly: true)
+ .with(totalSleepMin: 400, efficiency: 0.93, deepMin: 80, remMin: 100, lightMin: 220)
+ // Correcting the wake window does not change whether the strap banked any motion.
+ XCTAssertEqual(edited.sleepHrOnly, true)
+ }
+
+ func testAnImportedRowTakesTheComputedStagingFlag() {
+ let imported = row(day: "2026-09-03")
+ let computed = row(day: "2026-09-03", sleepHrOnly: true)
+ XCTAssertEqual(imported.fillingNilFields(from: computed).sleepHrOnly, true,
+ "only a scoring pass knows the staging; an import's nil must not erase it")
+ }
+
+ func testTheFlagMovesWithTheSleepColumnsItDescribes() {
+ let importRow = row(day: "2026-09-03", totalSleepMin: 300)
+ let editedComputed = row(day: "2026-09-03", totalSleepMin: 400, sleepHrOnly: true)
+ let merged = importRow.takingSleepFields(from: editedComputed)
+ XCTAssertEqual(merged.totalSleepMin, 400)
+ // The flag describes THOSE stage figures, so it travels with them rather than staying behind.
+ XCTAssertEqual(merged.sleepHrOnly, true)
+ }
+
+ /// The Swift twin of the Kotlin coalesce cases: the flag belongs to the sleep GROUP.
+ func testTheStagingFlagMovesWithTheSleepBlock() {
+ let winner = row(day: "2026-09-03", restingHr: 55)
+ let filler = row(day: "2026-09-03", totalSleepMin: 400, deepMin: 39,
+ remMin: 54, lightMin: 57, sleepHrOnly: true)
+ XCTAssertEqual(Repository.coalesceDay(winner, filler).sleepHrOnly, true)
+ }
+
+ func testAWinnerThatOwnsTheSleepBlockKeepsItsOwnStagingFlag() {
+ let winner = row(day: "2026-09-03", totalSleepMin: 420, deepMin: 90, sleepHrOnly: false)
+ let filler = row(day: "2026-09-03", totalSleepMin: 300, sleepHrOnly: true)
+ XCTAssertEqual(Repository.coalesceDay(winner, filler).sleepHrOnly, false)
+ }
}
diff --git a/StrandTests/DayCycleRecoveryTests.swift b/StrandTests/DayCycleRecoveryTests.swift
new file mode 100644
index 0000000000..55405b1e3e
--- /dev/null
+++ b/StrandTests/DayCycleRecoveryTests.swift
@@ -0,0 +1,88 @@
+import XCTest
+import StrandAnalytics
+import WhoopStore
+@testable import Strand
+
+@MainActor
+final class DayCycleRecoveryTests: XCTestCase {
+ private enum ReadFailure: Error { case injected }
+
+ func testApplyingCycleStepsPreservesUnrelatedDailyColumns() {
+ let daily = DailyMetric(
+ day: "2026-09-04", totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil,
+ lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, recovery: nil,
+ strain: nil, exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil,
+ steps: 10, activeKcalEst: nil, skinTempC: 34.2, sleepHrOnly: true)
+ let result = DayCycleIntelligenceIntegration.Result(
+ stepsByWakeDay: [daily.day: 42], strainByWakeDay: [daily.day: 61],
+ caloriesByWakeDay: [daily.day: 1_840], workoutCountByWakeDay: [daily.day: 2],
+ onsetByWakeDay: [:], firstWakeDay: daily.day,
+ markerUpdate: .preserve)
+
+ let updated = DayCycleIntelligenceIntegration.applying(result, to: daily)
+
+ XCTAssertEqual(updated.steps, 42)
+ XCTAssertEqual(updated.strain, 61)
+ XCTAssertEqual(updated.activeKcalEst, 1_840)
+ XCTAssertEqual(updated.exerciseCount, 2)
+ XCTAssertEqual(updated.skinTempC, 34.2)
+ XCTAssertEqual(updated.sleepHrOnly, true)
+ }
+
+ func testBoundaryRecoveryPropagatesSessionReadFailure() async {
+ let reader = DayCycleIntelligenceIntegration.BoundaryRecoveryReader(
+ sleepSessions: { _, _, _ in throw ReadFailure.injected },
+ markers: { _, _, _ in XCTFail("marker read must not follow a failed session read"); return [] })
+
+ do {
+ _ = try await DayCycleIntelligenceIntegration.recover(
+ candidates: [(owner: "strap", priority: 0)], reader: reader,
+ claimedDays: [], windowStart: 1_700_000_000, now: 1_700_086_400,
+ offsetSec: 0, habitualMidsleepSec: nil)
+ XCTFail("expected recovery to fail closed")
+ } catch ReadFailure.injected {
+ // Expected: callers can distinguish an unread namespace from an authoritative empty one.
+ } catch {
+ XCTFail("unexpected error: \(error)")
+ }
+ }
+
+ func testBoundaryRecoveryPropagatesMarkerReadFailure() async {
+ let reader = DayCycleIntelligenceIntegration.BoundaryRecoveryReader(
+ sleepSessions: { _, _, _ in [] },
+ markers: { _, _, _ in throw ReadFailure.injected })
+
+ do {
+ _ = try await DayCycleIntelligenceIntegration.recover(
+ candidates: [(owner: "strap", priority: 0)], reader: reader,
+ claimedDays: [], windowStart: 1_700_000_000, now: 1_700_086_400,
+ offsetSec: 0, habitualMidsleepSec: nil)
+ XCTFail("expected recovery to fail closed")
+ } catch ReadFailure.injected {
+ } catch {
+ XCTFail("unexpected error: \(error)")
+ }
+ }
+
+ func testComputePreservesMarkersWhenRecoveryCannotBeRead() async throws {
+ let store = try await WhoopStore.inMemory()
+ let reader = DayCycleIntelligenceIntegration.BoundaryRecoveryReader(
+ sleepSessions: { _, _, _ in throw ReadFailure.injected },
+ markers: { _, _, _ in XCTFail("marker read must not follow a failed session read"); return [] })
+
+ let result = await DayCycleIntelligenceIntegration.compute(
+ nights: [], editedRows: [], store: store,
+ candidates: [(owner: "strap", priority: 0)],
+ physiologyOwners: ["strap"], workouts: [],
+ windowStart: 1_700_000_000, now: 1_700_086_400, offsetSec: 0,
+ habitualMidsleepSec: nil, ticksPerStep: 1, mode: .sleepOnset,
+ cache: DayCycleIntelligenceIntegration.Cache(), profile: UserProfile(),
+ maxHROverride: nil, effortMethod: .edwards, recoveryReader: reader)
+
+ guard case .preserve = result.markerUpdate else {
+ return XCTFail("an unread marker namespace must never become an authoritative replacement")
+ }
+ XCTAssertTrue(result.stepsByWakeDay.isEmpty)
+ XCTAssertTrue(result.onsetByWakeDay.isEmpty)
+ }
+}
diff --git a/StrandTests/DeviceRawSourceParityTests.swift b/StrandTests/DeviceRawSourceParityTests.swift
new file mode 100644
index 0000000000..1230b0761a
--- /dev/null
+++ b/StrandTests/DeviceRawSourceParityTests.swift
@@ -0,0 +1,118 @@
+import Foundation
+import XCTest
+@testable import Strand
+import WhoopStore
+import WhoopProtocol
+
+/// Apple half of the shared Android/Apple raw-source oracle. This tests the repository contract rather
+/// than duplicating expected values in Swift, so source precedence and R-R identity cannot drift quietly.
+final class DeviceRawSourceParityTests: XCTestCase {
+ private struct Fixture: Decodable {
+ struct SourceCase: Decodable {
+ let name: String
+ let activeDeviceId: String
+ let registeredWhoopIds: [String]
+ let imported: [String]
+ let computed: [String]
+ }
+ struct Beat: Decodable {
+ let source: String?
+ let ts: Int
+ let rrMs: Int
+ let seq: Int
+ let ord: Int?
+ }
+ struct BeatSource: Decodable { let id: String; let beats: [Beat] }
+ struct RRCase: Decodable { let name: String; let sources: [BeatSource]; let expected: [Beat] }
+ let sourceCases: [SourceCase]
+ let rrCases: [RRCase]
+ }
+
+ private func fixture() throws -> Fixture {
+ let testFile = URL(fileURLWithPath: #filePath)
+ let url = testFile.deletingLastPathComponent().deletingLastPathComponent()
+ .appendingPathComponent("Tools/parity_cases/device_raw_sources.json")
+ XCTAssertTrue(FileManager.default.fileExists(atPath: url.path),
+ "Shared device-source parity fixture is missing; parity must fail closed")
+ return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url))
+ }
+
+ private func production(_ path: String) throws -> String {
+ let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
+ let url = root.appendingPathComponent(path)
+ XCTAssertTrue(FileManager.default.fileExists(atPath: url.path),
+ "Production source missing: \(path); consumer wiring guard must fail closed")
+ return try String(contentsOf: url, encoding: .utf8)
+ }
+
+ func testSourceResolutionMatchesSharedOracle() throws {
+ for vector in try fixture().sourceCases {
+ let actual = Repository.rawWhoopSourceIds(activeDeviceId: vector.activeDeviceId,
+ registeredWhoopIds: vector.registeredWhoopIds)
+ XCTAssertEqual(actual, vector.imported, vector.name)
+ XCTAssertEqual(actual.map { $0 + "-noop" }, vector.computed, vector.name)
+ }
+ }
+
+ func testRRIdentityMergeMatchesSharedOracle() throws {
+ for vector in try fixture().rrCases {
+ let inputs = vector.sources.map { source in
+ source.beats.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, ord: $0.ord, seq: $0.seq) }
+ }
+ let actual = Repository.mergeRRByIdentity(inputs)
+ XCTAssertEqual(actual.map(\.ts), vector.expected.map(\.ts), vector.name)
+ XCTAssertEqual(actual.map(\.rrMs), vector.expected.map(\.rrMs), vector.name)
+ XCTAssertEqual(actual.map(\.seq), vector.expected.map(\.seq), vector.name)
+ XCTAssertEqual(actual.map(\.ord), vector.expected.map(\.ord), vector.name)
+ }
+ }
+
+ func testStressCannotPinRawReadsToCanonicalNamespace() throws {
+ let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
+ let source = try String(contentsOf: root.appendingPathComponent("Strand/Screens/StressView.swift"),
+ encoding: .utf8)
+ let withoutComments = source.replacingOccurrences(of: #"/\*.*?\*/"#, with: "",
+ options: .regularExpression)
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .map { $0.split(separator: "//", maxSplits: 1).first.map(String.init) ?? "" }
+ .joined(separator: "\n")
+ let directCanonicalRaw = #"(?s)(hrSamples|rrIntervals|gravitySamples)\s*\([^)]*(\"my-whoop\"|Repository\.whoopSource)"#
+ XCTAssertNil(withoutComments.range(of: directCanonicalRaw, options: .regularExpression),
+ "Stress raw physiology must resolve via the device-aware repository API")
+ for call in ["repo.hrSamples(from:", "repo.rrIntervals(from:", "repo.gravitySamplesUnion(from:"] {
+ XCTAssertTrue(withoutComments.contains(call), "Stress must use device-aware \(call)")
+ }
+ }
+
+ func testCoachTimelineAndRhythmUseDeviceAwareRawFacades() throws {
+ let coach = try production("Strand/AI/AICoach.swift")
+ XCTAssertTrue(coach.contains("repo.rrIntervals(from:"),
+ "AI Coach stress context must use Repository's all-source R-R facade")
+ XCTAssertFalse(coach.contains("store.rrIntervals(\n deviceId: repo.deviceId"),
+ "AI Coach must not pin R-R to the currently active device")
+
+ let repository = try production("Strand/Data/Repository.swift")
+ guard let timelineStart = repository.range(of: "func timelineSeries(")?.lowerBound else {
+ return XCTFail("Repository.timelineSeries is missing")
+ }
+ let timeline = String(repository[timelineStart...].prefix(12_000))
+ XCTAssertTrue(timeline.contains("rawPhysiologyReadIds(store: store)"),
+ "Default Full Day timelines must resolve every registered raw source")
+ XCTAssertFalse(timeline.contains("source.map { [$0] } ?? importedReadIds"),
+ "Timeline must not fall back to active-plus-canonical only")
+ XCTAssertTrue(repository.contains("hrBuckets(deviceIds: rawPhysiologyReadIds(store: store)"),
+ "Coarse HR reads must include every registered WHOOP strap")
+ XCTAssertTrue(repository.contains("ids: rawPhysiologyReadIds(store: store)"),
+ "Primary sleep reads must include every registered WHOOP strap")
+ XCTAssertTrue(repository.contains("ids: rawComputedReadIds(store: store)"),
+ "Computed sleep reads must include every registered WHOOP strap")
+
+ let pillars = try production("Strand/Screens/V5PillarHosts.swift")
+ XCTAssertTrue(pillars.contains("repo.rrIntervals(from:"),
+ "Rhythm must use Repository's all-source R-R facade")
+ XCTAssertTrue(pillars.contains("repo.gravitySamplesUnion(from:"),
+ "Rhythm must use Repository's all-source gravity facade")
+ XCTAssertFalse(pillars.contains("store.rrIntervals(deviceId: repo.deviceId"),
+ "Rhythm must not pin R-R to the currently active device")
+ }
+}
diff --git a/StrandTests/DisUnbondedReadTests.swift b/StrandTests/DisUnbondedReadTests.swift
new file mode 100644
index 0000000000..2ccb03c002
--- /dev/null
+++ b/StrandTests/DisUnbondedReadTests.swift
@@ -0,0 +1,94 @@
+import XCTest
+@testable import Strand
+
+/// The unbonded Device Information Service read, ported from Android (#1647/#1649/#1653).
+///
+/// Since the #1635 suppression, "never bonded" is a PERMANENT state on a 5/MG rather than a transient
+/// one — so a post-bond-only DIS read means such a strap never learns its firmware, its serial or whether
+/// it is an MG at all. These rules decide when to try, and when to stop trying.
+final class DisUnbondedReadTests: XCTestCase {
+
+ // MARK: - when to attempt
+
+ func testACleanFiveMGAttemptsTheRead() {
+ XCTAssertTrue(shouldReadDisUnbonded(isWhoop5: true, bonded: false,
+ alreadyReadThisLink: false, previouslyRefused: false))
+ }
+
+ /// A 4.0 bonds cleanly and its post-bond read already works, so it has no business here.
+ func testAFourOhNeverAttemptsIt() {
+ XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: false, bonded: false,
+ alreadyReadThisLink: false, previouslyRefused: false))
+ }
+
+ func testABondedLinkLeavesItToThePostBondPath() {
+ XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: true, bonded: true,
+ alreadyReadThisLink: false, previouslyRefused: false))
+ }
+
+ func testItIsAtMostOncePerLink() {
+ XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: true, bonded: false,
+ alreadyReadThisLink: true, previouslyRefused: false))
+ }
+
+ /// The self-limiting half. A read is the same class of operation as the write that provokes the
+ /// teardown, so retrying one the strap has already refused risks the stable link the suppression
+ /// bought — and "keeps trying something that will never work" is the failure mode this whole area
+ /// keeps producing.
+ func testARefusalIsPermanentForThatStrap() {
+ XCTAssertFalse(shouldReadDisUnbonded(isWhoop5: true, bonded: false,
+ alreadyReadThisLink: false, previouslyRefused: true))
+ }
+
+ // MARK: - the refusal latch key
+
+ func testTheRefusalKeyIsCaseInsensitiveAndTrimmed() {
+ XCTAssertEqual(disRefusedPrefKey("AABB-11"), "noop.disRefused.aabb-11")
+ XCTAssertEqual(disRefusedPrefKey(" AABB-11 "), disRefusedPrefKey("aabb-11"))
+ }
+
+ func testTheRefusalKeyRejectsNothingToKeyOn() {
+ XCTAssertNil(disRefusedPrefKey(nil))
+ XCTAssertNil(disRefusedPrefKey(""))
+ XCTAssertNil(disRefusedPrefKey(" "))
+ }
+
+ /// The two latches this area now persists must never collide — they answer different questions about
+ /// the same strap, and one key serving both would make a DIS refusal suppress the handshake.
+ func testTheDisLatchCannotCollideWithTheHelloLatch() {
+ XCTAssertNotEqual(disRefusedPrefKey("aabb-11"), helloSuppressionPrefKey("aabb-11"))
+ }
+
+ // MARK: - the firmware fallback
+
+ /// DIS is the ONLY firmware source an unbonded strap has: the puffin report needs the bond that never
+ /// happens.
+ func testDisFirmwarePublishesWhenNothingWasDecoded() {
+ XCTAssertTrue(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: nil))
+ XCTAssertTrue(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: ""))
+ XCTAssertTrue(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: " "))
+ }
+
+ /// FALLBACK, never override. The puffin value is the strap's own account of what it is running and
+ /// lands later in the connect; a firmware that appeared and then changed would be worse than one that
+ /// arrived once.
+ func testDisFirmwareNeverOverridesTheDecodedValue() {
+ XCTAssertFalse(shouldPublishDisFirmware(disFirmware: "1.2.3", alreadyDecoded: "9.9.9"))
+ }
+
+ func testThereIsNothingToPublishWithoutADisString() {
+ XCTAssertFalse(shouldPublishDisFirmware(disFirmware: nil, alreadyDecoded: nil))
+ XCTAssertFalse(shouldPublishDisFirmware(disFirmware: " ", alreadyDecoded: nil))
+ }
+
+ // MARK: - the failure line (byte-identical to the Kotlin twin)
+
+ /// Generated by compiling the pure file standalone with swiftc and pasted verbatim, per the oracle
+ /// rule. Without this line a refused read is indistinguishable from one never issued — the ambiguity
+ /// that made the CLIENT_HELLO failure unreadable for eleven weeks.
+ func testTheFailureLineIsExact() {
+ XCTAssertEqual(
+ disReadFailureLine(uuid: "2A26", status: "err"),
+ "DIS: read of 2A26 failed err — the strap declined it on this link. If this is an insufficient-authentication or -encryption status, DIS needs an encrypted bond and the firmware cannot be read without one (#490)")
+ }
+}
diff --git a/StrandTests/FrameIntegrityGateTests.swift b/StrandTests/FrameIntegrityGateTests.swift
new file mode 100644
index 0000000000..c313fe7c2e
--- /dev/null
+++ b/StrandTests/FrameIntegrityGateTests.swift
@@ -0,0 +1,297 @@
+import XCTest
+import StrandAnalytics
+import WhoopProtocol
+import WhoopStore
+@testable import Strand
+
+/// End-to-end, parser → consumer, for the state-driving gates this app owns (frame-integrity, E3):
+/// the frame router, the history-metadata classifier, the clock correlation and the data-range reply.
+///
+/// Every frame below is a real or protocol-correct frame broken in exactly ONE way — a corrupted
+/// header checksum, a declared length under the family minimum, a cut-off CRC32 trailer — so what the
+/// consumer refuses is attributable to a single cause. Each test asserts the PRECONDITION that the
+/// frame still decodes, because a gate that only ever sees unreadable bytes proves nothing.
+///
+/// `@MainActor`: `FrameRouter` and `LiveState` are main-actor types.
+@MainActor
+final class FrameIntegrityGateTests: XCTestCase {
+
+ private func bytes(_ s: String) -> [UInt8] {
+ var out = [UInt8](); out.reserveCapacity(s.count / 2); var i = s.startIndex
+ while i < s.endIndex { let j = s.index(i, offsetBy: 2)
+ out.append(UInt8(s[i.. [UInt8] {
+ var f = bytes(realtimeHex); f[3] ^= 0xFF; return f
+ }
+
+ override func setUp() {
+ super.setUp()
+ UserDefaults.standard.removeObject(forKey: "testcentre.active.connection")
+ UserDefaults.standard.removeObject(forKey: "testcentre.active.master")
+ }
+
+ override func tearDown() {
+ UserDefaults.standard.removeObject(forKey: "testcentre.active.connection")
+ UserDefaults.standard.removeObject(forKey: "testcentre.active.master")
+ super.tearDown()
+ }
+
+ // MARK: - the router drives no state from a frame that is not intact
+
+ func testRouterRejectsABrokenHeaderChecksum() {
+ let frame = headerBroken()
+ let parsed = parseFrame(frame, family: .whoop4)
+ XCTAssertEqual(parsed.crcOK, true, "precondition: the payload CRC32 verifies")
+ XCTAssertEqual(parsed.parsed["heart_rate"]?.intValue, 60, "precondition: the HR still decodes")
+
+ let live = LiveState()
+ let router = FrameRouter(state: live)
+ router.handle(frame: frame)
+ XCTAssertNil(live.heartRate, "a frame that is not intact must not reach live state")
+ XCTAssertNil(live.lastFrameType, "…and must not be forwarded to any downstream consumer")
+ }
+
+ func testRouterAcceptsTheSameFrameIntact() {
+ let live = LiveState()
+ let router = FrameRouter(state: live)
+ router.handle(frame: bytes(realtimeHex))
+ XCTAssertEqual(live.heartRate, 60, "control: the untouched frame still drives live state")
+ XCTAssertEqual(live.lastFrameType, "REALTIME_DATA")
+ }
+
+ func testRouterRejectsADeclaredLengthBelowTheFamilyMinimum() {
+ var frame = bytes(realtimeHex)
+ frame[1] = 6; frame[2] = 0 // declared 6 → total 10, under the 11-byte floor
+ frame[3] = crc8(frame, 1, 3) // with a CORRECT checksum for that length word
+ XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .belowMinimumLength)
+
+ let live = LiveState()
+ FrameRouter(state: live).handle(frame: frame)
+ XCTAssertNil(live.heartRate)
+ XCTAssertNil(live.lastFrameType)
+ }
+
+ func testRouterRejectsATruncatedTrailer() {
+ let frame = Array(bytes(realtimeHex).dropLast(2))
+ let parsed = parseFrame(frame, family: .whoop4)
+ XCTAssertEqual(parsed.rejectReason, .lengthMismatch)
+ XCTAssertEqual(parsed.typeName, "REALTIME_DATA", "precondition: it still reads as a frame")
+
+ let live = LiveState()
+ FrameRouter(state: live).handle(frame: frame)
+ XCTAssertNil(live.heartRate)
+ XCTAssertNil(live.lastFrameType)
+ }
+
+ func testRouterRejectsTrailingBytes() {
+ let frame = bytes(realtimeHex) + [0x00]
+ XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .lengthMismatch)
+ let live = LiveState()
+ FrameRouter(state: live).handle(frame: frame)
+ XCTAssertNil(live.heartRate)
+ }
+
+ // MARK: - what the router SAYS about a rejection (D3)
+
+ /// Rare-event evidence stays visible with every test mode OFF: the class that used to pass is the
+ /// one the hardware run's abort criterion reads, and it costs nothing on a link where it never
+ /// happens.
+ func testTheClassThatUsedToPassIsAnnouncedWithoutAnyTestMode() {
+ XCTAssertFalse(TestCentre.active(.connection))
+ let live = LiveState()
+ let router = FrameRouter(state: live)
+ router.handle(frame: headerBroken())
+ XCTAssertEqual(router.rejectTally.payloadCRCOKButEnvelopeRejected, 1)
+ XCTAssertEqual(router.rejectTally.count(.headerChecksumMismatch), 1)
+ XCTAssertTrue(live.log.contains { $0.contains("payload CRC32 verified") },
+ "the always-on line must name the class: \(live.log)")
+ // Announced ONCE, at the first sighting — a noisy link must not repeat it per frame.
+ router.handle(frame: headerBroken())
+ XCTAssertEqual(live.log.filter { $0.contains("payload CRC32 verified") }.count, 1)
+ XCTAssertEqual(router.rejectTally.payloadCRCOKButEnvelopeRejected, 2, "…while the count keeps rising")
+ }
+
+ /// The ordinary per-connection detail is Test-Centre gated, and silent when the mode is off.
+ func testPerConnectionRejectDetailIsGatedBehindTheConnectionDomain() {
+ let live = LiveState()
+ let router = FrameRouter(state: live)
+ router.handle(frame: Array(bytes(realtimeHex).dropLast(2))) // lengthMismatch, CRC32 unknown
+ XCTAssertTrue(live.taggedTail(domain: .connection).isEmpty,
+ "mode off must emit zero tagged lines: \(live.taggedTail(domain: .connection))")
+
+ TestCentre.activate(.connection)
+ defer { TestCentre.deactivate(.connection) }
+ let live2 = LiveState()
+ let router2 = FrameRouter(state: live2)
+ router2.handle(frame: Array(bytes(realtimeHex).dropLast(2)))
+ let tagged = live2.taggedTail(domain: .connection)
+ XCTAssertEqual(tagged.count, 1, "one line per reason, got \(tagged)")
+ XCTAssertTrue(tagged[0].contains("frameReject reason=lengthMismatch"), tagged[0])
+ XCTAssertTrue(tagged[0].contains("type=REALTIME_DATA"),
+ "a rejected frame keeps its packet type in the diagnosis: \(tagged[0])")
+ // One line per REASON, not per frame.
+ router2.handle(frame: Array(bytes(realtimeHex).dropLast(2)))
+ XCTAssertEqual(live2.taggedTail(domain: .connection).count, 1)
+ }
+
+ /// A byte run the reassembler drops never reaches the router at all; folding its monotonic counter
+ /// in is what keeps its disappearance visible.
+ func testReassemblerDropsAreFoldedIntoTheConnectionTally() {
+ let live = LiveState()
+ let router = FrameRouter(state: live)
+ let r = Reassembler(family: .whoop4)
+ let runt: [UInt8] = [0xAA, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] // declares 8 total
+ let completed = r.feed(runt + bytes(realtimeHex))
+ router.noteReassemblerDrops(r.belowMinimumLengthDrops)
+ XCTAssertEqual(completed.count, 1, "the stream resyncs onto the real frame")
+ XCTAssertEqual(router.rejectTally.count(.belowMinimumLength), r.belowMinimumLengthDrops)
+ XCTAssertGreaterThan(r.belowMinimumLengthDrops, 0)
+ }
+
+ // MARK: - the clock correlation takes no anchor from a frame that is not intact (2.14)
+
+ /// A WHOOP 4.0 GET_CLOCK COMMAND_RESPONSE carrying a device clock value.
+ ///
+ /// The command byte must be the REAL `GET_CLOCK` opcode: the decoder only publishes `clock` when
+ /// the schema resolves `frame[6]` to `GET_CLOCK`, so a made-up opcode yields a frame that verifies
+ /// and parses but carries no anchor at all — a gate test written on one would pass while proving
+ /// nothing about the gate.
+ private func clockResponse() -> [UInt8] {
+ // [type 36][seq][cmd = GET_CLOCK][resp_seq][result][clock u32 LE]
+ frameFromPayload([0x0A, 0x01, 0x8F, 0x64, 0xE1, 0x01],
+ type: 36, seq: 1, cmd: WhoopCommand.getClock.rawValue)
+ }
+
+ /// The helper is only meaningful if it really carries an anchor — pinned separately so a future
+ /// opcode change fails HERE, with the cause named, instead of quietly hollowing out the two gate
+ /// tests below into assertions that nil equals nil.
+ func testTheClockResponseHelperActuallyCarriesAClock() {
+ let parsed = parseFrame(clockResponse(), family: .whoop4)
+ XCTAssertEqual(parsed.typeName, "COMMAND_RESPONSE")
+ XCTAssertEqual(parsed.parsed["clock"]?.intValue, 31_548_559,
+ "the helper must decode a clock, or the gate tests prove nothing")
+ }
+
+ func testClockRefIsTakenFromAnIntactResponse() {
+ let parsed = parseFrame(clockResponse(), family: .whoop4)
+ XCTAssertTrue(parsed.ok, "precondition: the response is intact")
+ XCTAssertNotNil(parsed.parsed["clock"]?.intValue, "precondition: it carries a clock")
+ XCTAssertNotNil(ClockCorrelation.clockRef(from: parsed, wall: 1_736_365_593),
+ "control: the useful path still anchors")
+ }
+
+ func testClockRefIsNotTakenFromABrokenHeaderChecksum() {
+ var frame = clockResponse()
+ frame[3] ^= 0xFF
+ let parsed = parseFrame(frame, family: .whoop4)
+ XCTAssertEqual(parsed.crcOK, true, "precondition: only the header checksum is broken")
+ XCTAssertNotNil(parsed.parsed["clock"]?.intValue, "precondition: the clock value still decodes")
+ XCTAssertNil(ClockCorrelation.clockRef(from: parsed, wall: 1_736_365_593),
+ "an anchor from a damaged frame mis-stamps every row that follows")
+ }
+
+ func testClockRefIsNotTakenFromADeclaredLengthBelowTheMinimum() {
+ var frame = clockResponse()
+ frame[1] = 6; frame[2] = 0
+ frame[3] = crc8(frame, 1, 3)
+ let parsed = parseFrame(frame, family: .whoop4)
+ XCTAssertEqual(parsed.rejectReason, .belowMinimumLength)
+ XCTAssertNil(ClockCorrelation.clockRef(from: parsed, wall: 1_736_365_593))
+ }
+
+ // MARK: - the data-range reply (2.15)
+
+ /// A protocol-correct WHOOP 4.0 GET_DATA_RANGE COMMAND_RESPONSE (built here, not captured): its
+ /// plausible-unix words are the window the offload judges every drained record against (#547).
+ private func dataRangeResponse() -> [UInt8] {
+ // The two-byte response header, a filler word, then the oldest and newest markers on the
+ // 4-byte grid the oldest-scan reads (frame[11] and frame[15]).
+ let payload: [UInt8] = [0x0A, 0x01, 0x00, 0x00,
+ 0x00, 0xF1, 0x53, 0x65, // 1_700_000_000 LE
+ 0x70, 0xE1, 0x4C, 0x68] // 1_749_868_912 LE
+ return frameFromPayload(payload, type: 36, seq: 1, cmd: WhoopCommand.getDataRange.rawValue)
+ }
+
+ func testTheDataRangeWindowIsReadFromAnIntactReply() {
+ let frame = dataRangeResponse()
+ XCTAssertTrue(parseFrame(frame, family: .whoop4).ok, "precondition: intact")
+ XCTAssertEqual(BLEManager.dataRangeOldestUnix(from: frame), 1_700_000_000)
+ XCTAssertEqual(BLEManager.dataRangeNewestUnix(from: frame, wallNowUnix: 1_760_000_000),
+ 1_749_868_912)
+ }
+
+ /// The gate the BLE seam applies before it hands this reply to `handleDataRangeResponse`: the
+ /// bytes still scan to a window, and that is exactly why the verdict has to be consulted. A reply
+ /// with a broken header checksum narrows the plausibility window, the sync's real records fall
+ /// through it, the section persists nothing — and is acknowledged anyway.
+ func testABrokenDataRangeReplyIsRefusedByTheVerdictTheSeamChecks() {
+ var frame = dataRangeResponse()
+ frame[3] ^= 0xFF
+ let parsed = parseFrame(frame, family: .whoop4)
+ XCTAssertFalse(parsed.ok, "the seam's condition is `parsed.ok`, and it must be false here")
+ XCTAssertEqual(parsed.rejectReason, .headerChecksumMismatch)
+ XCTAssertEqual(parsed.crcOK, true, "…even though the payload CRC32 verifies")
+ // The scan itself is byte-level and would happily answer — which is the point of the gate.
+ XCTAssertNotNil(BLEManager.dataRangeNewestUnix(from: frame, wallNowUnix: 1_760_000_000))
+ }
+
+ func testATruncatedDataRangeReplyIsRefusedByTheSameVerdict() {
+ let frame = Array(dataRangeResponse().dropLast(2))
+ XCTAssertFalse(parseFrame(frame, family: .whoop4).ok)
+ XCTAssertEqual(parseFrame(frame, family: .whoop4).rejectReason, .lengthMismatch)
+ }
+
+ // MARK: - the app's own direct call into the verifier (2.5, the fifth caller)
+
+ /// A 5/MG COMMAND_RESPONSE for one of the four ECG opcodes, in the puffin envelope the ECG probe's
+ /// frame triage reads: type @8, cmd @10, result code @12.
+ private func ecgProbeReply(result: UInt8 = 0x01) -> [UInt8] {
+ puffinCommandFrame(cmd: WhoopCommand.toggleLabradorFiltered.rawValue, seq: 1,
+ payload: [0x42, result, 0x00, 0x00], type: 0x24)
+ }
+
+ /// `BLEManager.noteEcgProbeFrame` is the ONE place the app calls `verifyFrame` itself rather than
+ /// going through a parser, and D2 requires each of the five direct callers to have its useful path
+ /// shown intact under the tightened verifier. Here that path is: a well-formed reply verifies, and
+ /// the probe's own decoder reads the outcome the report will state.
+ func testTheEcgProbeGateStillAdmitsAWellFormedReply() {
+ let frame = ecgProbeReply()
+ XCTAssertTrue(verifyFrame(frame, family: .whoop5).ok,
+ "the probe's gate is exactly this call; the useful path must stay open")
+ XCTAssertEqual(frame[8], 0x24, "precondition: the triage's COMMAND_RESPONSE type byte")
+ XCTAssertEqual(frame[10], WhoopCommand.toggleLabradorFiltered.rawValue,
+ "precondition: the triage's opcode byte")
+ XCTAssertEqual(Whoop5EcgProbe.outcome(frame: frame), .success,
+ "control: the outcome the probe would report from this reply")
+ }
+
+ /// And the other half of why that gate is there. The result byte of a damaged reply still decodes,
+ /// so without the verdict the probe would print the strongest claim it can make — a refusal or a
+ /// success — about a frame the strap may never have sent that way. Silence is the correct output.
+ func testTheEcgProbeGateRefusesADamagedReplyWhoseResultByteWouldStillDecode() {
+ var frame = ecgProbeReply(result: 0x00) // 0 = FAILURE, the loudest verdict
+ frame[6] ^= 0xFF // break only the CRC16 header checksum
+ let check = verifyFrame(frame, family: .whoop5)
+ XCTAssertFalse(check.ok, "the probe must read no byte of this frame")
+ XCTAssertEqual(check.reason, .headerChecksumMismatch)
+ XCTAssertEqual(check.crc32OK, true, "…while the payload CRC32 still verifies")
+ XCTAssertEqual(Whoop5EcgProbe.outcome(frame: frame), .failure,
+ "the byte-level decode would happily answer — that is what the gate stops")
+ }
+
+ func testTheEcgProbeGateRefusesTrailingBytesAndTruncation() {
+ XCTAssertEqual(verifyFrame(ecgProbeReply() + [0x00], family: .whoop5).reason, .lengthMismatch)
+ XCTAssertEqual(verifyFrame(Array(ecgProbeReply().dropLast(2)), family: .whoop5).reason,
+ .lengthMismatch)
+ }
+}
diff --git a/StrandTests/FusionFormatUnitsTests.swift b/StrandTests/FusionFormatUnitsTests.swift
new file mode 100644
index 0000000000..2580db990c
--- /dev/null
+++ b/StrandTests/FusionFormatUnitsTests.swift
@@ -0,0 +1,43 @@
+import XCTest
+import StrandAnalytics
+@testable import Strand
+
+/// The fused-record row renders a skin temperature that is STORED in °C. It printed a hardcoded "°C"
+/// regardless of the reader's preference, so a Fahrenheit user saw a Celsius number on the fusion screen
+/// while every other surface converted it.
+final class FusionFormatUnitsTests: XCTestCase {
+
+ /// An ABSOLUTE wrist reading (CSV / Apple Health) converts with the +32 offset.
+ func testAbsoluteSkinTempFollowsTheReadersUnit() {
+ XCTAssertEqual(FusionFormat.value(34.1, metricKey: "skin_temp", temperature: .celsius), "34.1 °C")
+ // 34.1 °C = 93.38 °F
+ XCTAssertEqual(FusionFormat.value(34.1, metricKey: "skin_temp", temperature: .fahrenheit), "93.4 °F")
+ }
+
+ /// A DEVIATION (the live BLE pipeline) scales by 9/5 with NO offset and is chipped "Δ". This is the
+ /// half a naive `temperatureFromCelsius` call gets wrong: it would render +0.7 as "33.3 °F".
+ func testDeviationSkinTempKeepsItsDeltaChipAndSkipsTheOffset() {
+ XCTAssertEqual(FusionFormat.value(0.7, metricKey: "skin_temp", temperature: .celsius), "+0.7 Δ°C")
+ // 0.7 × 9/5 = 1.26 — NOT 33.3
+ XCTAssertEqual(FusionFormat.value(0.7, metricKey: "skin_temp", temperature: .fahrenheit), "+1.3 Δ°F")
+ // The regression #111/#622 names by number: the absolute formula turned this into "24.4 °F".
+ XCTAssertEqual(FusionFormat.value(-4.2, metricKey: "skin_temp", temperature: .fahrenheit), "-7.6 Δ°F")
+ }
+
+ /// Fusion is the screen that can show both modes side by side, so the split must come from the value
+ /// and land either side of VitalBands' 20 °C boundary.
+ func testTheModeIsDecidedByTheValueNotTheCaller() {
+ XCTAssertTrue(FusionFormat.value(19.9, metricKey: "skin_temp", temperature: .celsius).contains("Δ"))
+ XCTAssertFalse(FusionFormat.value(20.0, metricKey: "skin_temp", temperature: .celsius).contains("Δ"))
+ }
+
+ /// The unit must reach ONLY the temperature branch: every other metric formats identically either
+ /// way, so a threaded parameter cannot quietly change bpm/ms/steps.
+ func testNonTemperatureMetricsAreUnaffectedByTheUnit() {
+ for key in ["resting_hr", "avg_hr", "hrv", "steps", "spo2", "active_kcal", "asleep_min"] {
+ XCTAssertEqual(FusionFormat.value(62, metricKey: key, temperature: .celsius),
+ FusionFormat.value(62, metricKey: key, temperature: .fahrenheit),
+ "\(key) changed with the temperature unit")
+ }
+ }
+}
diff --git a/StrandTests/GpsRouteMathTests.swift b/StrandTests/GpsRouteMathTests.swift
index 353a606e33..6c5d4bdeac 100644
--- a/StrandTests/GpsRouteMathTests.swift
+++ b/StrandTests/GpsRouteMathTests.swift
@@ -240,4 +240,80 @@ final class GpsRouteMathTests: XCTestCase {
XCTAssertNil(RouteStore.load(startTs: 1_700_000_500, sport: "Walking", from: defaults))
XCTAssertTrue(RouteStore.loadMap(from: defaults).isEmpty)
}
+
+ // MARK: - #1205: imported GPS route lands under the key WorkoutDetailView loads
+
+ /// #1205: a route imported from Apple Health (HKWorkoutRoute) or Health Connect
+ /// (ExerciseRouteResult.Data) is stored via `RouteStore.store` under the workout's natural
+ /// key (startTs, sport). `WorkoutDetailView.load()` reads from that same key, so the imported
+ /// route appears on the detail screen with no UI change. This test pins that contract: the
+ /// store/load round-trip must survive under the exact key pair an imported workout carries.
+ func testImportedRouteRoundTripsUnderWorkoutNaturalKey() {
+ let defaults = freshDefaults()
+ let startTs = 1_700_000_000
+ let sport = "Running"
+ // Simulate what HealthKitBridge.collectWorkouts does after fetching an HKWorkoutRoute:
+ // encode the GPS points and store the resulting WorkoutRoute under the workout's key.
+ let points = [a, b, RouteMath.LatLng(51.4995, -0.1357)]
+ let route = WorkoutRoute(polyline: RouteMath.encode(points),
+ distanceM: RouteMath.totalMeters(points))
+ RouteStore.store(route, startTs: startTs, sport: sport, into: defaults)
+ // WorkoutDetailView.load() reads from the same key and decodes the polyline.
+ let loaded = RouteStore.load(startTs: startTs, sport: sport, from: defaults)
+ XCTAssertEqual(loaded, route)
+ let decoded = RouteMath.decode(loaded?.polyline ?? "")
+ XCTAssertEqual(decoded.count, points.count)
+ for i in points.indices {
+ XCTAssertEqual(decoded[i].lat, points[i].lat, accuracy: 1e-5)
+ XCTAssertEqual(decoded[i].lon, points[i].lon, accuracy: 1e-5)
+ }
+ }
+
+ /// The batched write an import uses must be indistinguishable from a run of single stores.
+ ///
+ /// `collectWorkouts` collects every imported route and calls `storeAll` once, because `store`
+ /// rewrites the whole capped map per call and an import calls it per workout. This pins that the
+ /// shortcut costs nothing: same keys, same values, and the same oldest-first eviction when the batch
+ /// overflows [RouteStore.maxRoutes].
+ func testStoreAllMatchesRepeatedSingleStores() {
+ let sport = "Running"
+ func route(_ i: Int) -> WorkoutRoute {
+ let pts = [RouteMath.LatLng(51.5 + Double(i) / 1000, -0.12),
+ RouteMath.LatLng(51.5 + Double(i) / 1000, -0.13)]
+ return WorkoutRoute(polyline: RouteMath.encode(pts), distanceM: RouteMath.totalMeters(pts))
+ }
+ // More than the cap, so eviction is exercised rather than assumed.
+ let n = RouteStore.maxRoutes + 25
+ let entries = (0.. GravitySample {
+ GravitySample(ts: ts, x: x, y: 0, z: 1)
+ }
+
+ /// The active strap wins a shared timestamp, and nothing double-counts — the two properties the
+ /// screen's motion total depends on.
+ func testActiveWinsTiesAndTheStreamIsTimeOrdered() {
+ let active = [sample(101, 9.0), sample(103, 9.2)]
+ let canonical = [sample(100, 5.0), sample(101, 5.1), sample(102, 5.2)]
+
+ let merged = Repository.mergeGravityByTs([active, canonical])
+
+ XCTAssertEqual(merged.map(\.ts), [100, 101, 102, 103])
+ XCTAssertEqual(merged.first { $0.ts == 101 }?.x, 9.0) // active, not canonical
+ }
+
+ /// A re-added strap with no canonical history must still populate: this is the reported failure on
+ /// the Android side, and the union has to cover it here too.
+ func testActiveOnlyMotionSurfacesWhenCanonicalIsEmpty() {
+ let active = [sample(100, 0.1), sample(101, 0.2)]
+ XCTAssertEqual(Repository.mergeGravityByTs([active, []]).map(\.ts), [100, 101])
+ }
+
+ /// The mirror case, which is the half THIS platform had: canonical history and no active-id rows.
+ func testCanonicalHistorySurfacesWhenTheActiveIdIsEmpty() {
+ let canonical = [sample(100, 0.1), sample(101, 0.2)]
+ XCTAssertEqual(Repository.mergeGravityByTs([[], canonical]).map(\.ts), [100, 101])
+ }
+
+ /// A single-device install collapses to one id, and that read is returned UNCHANGED — so this change
+ /// cannot alter what the overwhelmingly common install already saw.
+ func testASingleIdReadIsReturnedUntouched() {
+ let rows = [sample(100, 0.1), sample(101, 0.2)]
+ XCTAssertEqual(Repository.mergeGravityByTs([rows]), rows)
+ }
+
+ func testNoMotionUnderEitherIdStaysEmpty() {
+ XCTAssertTrue(Repository.mergeGravityByTs([[], []]).isEmpty)
+ }
+
+ /// Unsorted input still comes back time-ordered: the store returns rows per id, and concatenating two
+ /// id-ordered lists is not itself ordered.
+ func testOutOfOrderInputIsSorted() {
+ let a = [sample(300, 1), sample(100, 2)]
+ let b = [sample(200, 3)]
+ XCTAssertEqual(Repository.mergeGravityByTs([a, b]).map(\.ts), [100, 200, 300])
+ }
+}
diff --git a/StrandTests/HeaderRedactionTests.swift b/StrandTests/HeaderRedactionTests.swift
index f1c4784050..cdb306207f 100644
--- a/StrandTests/HeaderRedactionTests.swift
+++ b/StrandTests/HeaderRedactionTests.swift
@@ -29,6 +29,27 @@ final class HeaderRedactionTests: XCTestCase {
XCTAssertFalse(containsRawMac(LiveState.redactPii(line)))
}
+ /// #1303: once a strap ADOPTS its serial the device id IS the serial, and this header enumerates every
+ /// paired device. Neither older rule covers that shape — the MAC rule wants MAC form, the serial rule
+ /// wants the literal "WHOOP " then a digit — so before this the header leaked it verbatim.
+ func testAnAdoptedSerialIdIsMasked() {
+ let line = " device id=whoop-MGB1234567 status=ACTIVE brand=WHOOP model=5.0 MG lastSeen=1h ago"
+ let safe = LiveState.redactPii(line)
+ XCTAssertFalse(safe.contains("1234567"), "serial survived: \(safe)")
+ XCTAssertTrue(safe.contains("whoop-MGB…"), "three-character prefix should remain: \(safe)")
+ }
+
+ /// The `-noop` suffix is not identifying and is what separates derived rows from measured ones in the
+ /// per-source counts, so it must survive the mask that hides the serial in front of it.
+ func testTheComputedSiblingMarkerSurvives() {
+ XCTAssertEqual(LiveState.redactPii("Days: whoop-MGB1234567-noop=25"), "Days: whoop-MGB…-noop=25")
+ }
+
+ /// Anything too short to be accepted as a serial upstream must not be masked as one here either.
+ func testAnIdTooShortToBeASerialIsUntouched() {
+ XCTAssertEqual(LiveState.redactPii("id=whoop-ABCDE"), "id=whoop-ABCDE")
+ }
+
func testAnIdWithNoAddressIsUntouched() {
let line = " device id=my-whoop status=ACTIVE brand=WHOOP model=WHOOP 4.0 lastSeen=3h 10m ago"
XCTAssertEqual(LiveState.redactPii(line), line)
diff --git a/StrandTests/HelloSuppressionTests.swift b/StrandTests/HelloSuppressionTests.swift
new file mode 100644
index 0000000000..faefe702c3
--- /dev/null
+++ b/StrandTests/HelloSuppressionTests.swift
@@ -0,0 +1,212 @@
+import XCTest
+import WhoopProtocol
+@testable import Strand
+
+/// The #1635 CLIENT_HELLO suppression, ported from Android.
+///
+/// The rules are pure so they can be pinned without a radio — which matters more here than usual, because
+/// nothing else validates this path: BLE behaviour is not CI-testable, and the app target has no default
+/// CI at all.
+final class HelloSuppressionTests: XCTestCase {
+
+ // MARK: - shouldSendClientHello
+
+ func testTheHelloIsSentUntilTheLatchSets() {
+ XCTAssertTrue(shouldSendClientHello(suppressedForDevice: false, userInitiated: false))
+ XCTAssertFalse(shouldSendClientHello(suppressedForDevice: true, userInitiated: false))
+ }
+
+ /// Suppression is a fallback for AUTOMATIC reconnects, never a permanent verdict. Someone who puts the
+ /// strap in pairing mode and presses Connect must get a fresh try — that is also the only way to clear
+ /// the latch short of a genuine bond.
+ func testAnExplicitConnectAlwaysReattempts() {
+ XCTAssertTrue(shouldSendClientHello(suppressedForDevice: true, userInitiated: true))
+ XCTAssertTrue(shouldSendClientHello(suppressedForDevice: false, userInitiated: true))
+ }
+
+ // MARK: - giveUpSuppressesHello
+
+ /// The two give-up causes want OPPOSITE treatment, and collapsing them is the bug this split exists to
+ /// undo: before it, a strap that could still stream live HR was treated exactly like one that could not.
+ func testOnlyAnUnansweredHandshakeSuppresses() {
+ XCTAssertTrue(giveUpSuppressesHello(authRefusal: false)) // vanished write -> keep the link
+ XCTAssertFalse(giveUpSuppressesHello(authRefusal: true)) // strap said no -> pause instead
+ }
+
+ // MARK: - countsAsBondRefusal
+
+ /// The family gate is what keeps a WHOOP 4.0 out of this entirely. The latch is stored per device with
+ /// no family in the key, so without this gate a 4.0 could latch and silently lose history sync.
+ func testAFourOhCanNeverReachTheSuppression() {
+ for family in DeviceFamily.allCases where family != .whoop5 {
+ XCTAssertFalse(
+ countsAsBondRefusal(isAuthRefusalStatus: true, helloUnacked: true,
+ alreadyBonded: false, family: family),
+ "\(family) must not count toward the 5/MG handshake give-up")
+ }
+ }
+
+ func testBothCausesCountForAFiveMG() {
+ // The strap actively declined.
+ XCTAssertTrue(countsAsBondRefusal(isAuthRefusalStatus: true, helloUnacked: false,
+ alreadyBonded: false, family: .whoop5))
+ // The write simply vanished — invisible to this platform's give-up before the port.
+ XCTAssertTrue(countsAsBondRefusal(isAuthRefusalStatus: false, helloUnacked: true,
+ alreadyBonded: false, family: .whoop5))
+ // Neither: an ordinary drop is not a pairing problem.
+ XCTAssertFalse(countsAsBondRefusal(isAuthRefusalStatus: false, helloUnacked: false,
+ alreadyBonded: false, family: .whoop5))
+ }
+
+ /// A bonded link that drops is not a pairing failure, and counting it would latch the suppression on a
+ /// strap whose handshake demonstrably works.
+ func testAlreadyBondedNeverCounts() {
+ XCTAssertFalse(countsAsBondRefusal(isAuthRefusalStatus: true, helloUnacked: true,
+ alreadyBonded: true, family: .whoop5))
+ }
+
+ // MARK: - the latch key
+
+ /// Lowercased for the same reason the firmware key is: the same strap can present its identifier in
+ /// different cases across sessions, and a case-sensitive key would latch a second time under a second
+ /// key instead of reading the first.
+ func testTheLatchKeyIsCaseInsensitiveAndTrimmed() {
+ let upper = helloSuppressionPrefKey("AABBCCDD-1122")
+ XCTAssertEqual(upper, "noop.helloUnanswered.aabbccdd-1122")
+ XCTAssertEqual(helloSuppressionPrefKey(" aabbccdd-1122 "), upper)
+ }
+
+ func testTheLatchKeyRejectsNothingToKeyOn() {
+ XCTAssertNil(helloSuppressionPrefKey(nil))
+ XCTAssertNil(helloSuppressionPrefKey(""))
+ XCTAssertNil(helloSuppressionPrefKey(" "))
+ }
+
+ // MARK: - the latch itself
+
+ func testTheLatchRoundTripsAndClearsCompletely() {
+ let id = "unit-test-\(#function)"
+ defer { HelloSuppressionStore.setSuppressed(id, false) }
+
+ XCTAssertFalse(HelloSuppressionStore.suppressed(id))
+ HelloSuppressionStore.setSuppressed(id, true)
+ XCTAssertTrue(HelloSuppressionStore.suppressed(id))
+ // Case must not open a second latch beside the first.
+ XCTAssertTrue(HelloSuppressionStore.suppressed(id.uppercased()))
+ HelloSuppressionStore.setSuppressed(id, false)
+ XCTAssertFalse(HelloSuppressionStore.suppressed(id))
+ // Cleared means REMOVED, so a strap that never latched leaves nothing behind.
+ XCTAssertNil(UserDefaults.standard.object(forKey: helloSuppressionPrefKey(id)!))
+ }
+
+ /// An unkeyable id must not throw or write a stray default.
+ func testStoringAgainstNothingIsANoOp() {
+ HelloSuppressionStore.setSuppressed(nil, true)
+ XCTAssertFalse(HelloSuppressionStore.suppressed(nil))
+ }
+
+ // MARK: - the hints (byte-identical to the Kotlin twin)
+
+ /// The suppression branch pauses NOTHING, so its hint must not say auto-reconnect stopped — that was
+ /// the confidently-wrong diagnostic #1635 produced twice. It names what was lost and the one action
+ /// that restores the attempt.
+ func testTheSuppressionHintDoesNotClaimAnythingIsPaused() {
+ let hint = BondRefusalGiveUp.helloSuppressedHint()
+ XCTAssertTrue(hint.contains("live heart rate keeps streaming"))
+ XCTAssertTrue(hint.contains("History sync stays unavailable"))
+ // #1635 follow-up: the hint no longer LEADS with a retry. Framing a strap that refuses pairing as
+ // a retryable failure invited the hammering the give-up latch exists to stop, and a field report
+ // read the whole hint and still asked how to fix it. Pairing mode comes first now; Connect follows.
+ XCTAssertTrue(hint.contains("pairing mode"))
+ XCTAssertTrue(hint.contains("tap Connect"))
+ // And it must NOT claim these are unavailable: since #1884 an HR-only night reports both.
+ XCTAssertFalse(hint.contains("HRV and resting heart rate are unavailable"))
+ // The hint must name what an unbonded strap actually costs, not just history sync: motion,
+ // skin temperature, SpO2 and respiratory stop, so sleep falls to the HR-only stager. A field log
+ // showed a week of blank Recovery Vitals under the old wording, which pointed only at a feature
+ // the user was not missing. (It used to end "and HRV + resting HR are nulled" — #1884 made that
+ // false, and leaving it here would have contradicted the assertion four lines down.)
+ XCTAssertTrue(hint.contains("motion"))
+ // #1884 repinned the tail of this: naming HRV and resting HR as LOSSES stopped being true when an
+ // HR-only night began reporting both. What survives from #1878 is the reason they were named at
+ // all - the strap stops sending motion, so sleep falls to the HR-only stager. That still holds.
+ XCTAssertTrue(hint.contains("staged from heart rate alone"))
+ XCTAssertFalse(hint.lowercased().contains("paused"))
+ XCTAssertFalse(hint.lowercased().contains("stopped retrying"))
+ }
+
+ /// The unanswered-handshake hint must not name a cause. `pausedHint` blames the official WHOOP app or a
+ /// stale pairing, which an INSUFFICIENT_AUTHENTICATION refusal supports and a vanished write does not.
+ func testTheUnansweredHintNamesNoCause() {
+ let unanswered = BondRefusalGiveUp.pausedHintHandshakeUnanswered()
+ XCTAssertTrue(unanswered.contains("never completes"))
+ XCTAssertFalse(unanswered.contains("WHOOP app"))
+ XCTAssertFalse(unanswered.contains("Forget This Device"))
+ // …whereas the auth-refusal hint is exactly where naming that cause belongs.
+ XCTAssertTrue(BondRefusalGiveUp.pausedHint().contains("WHOOP app"))
+ }
+
+ /// Likewise the epitaph: `epitaphLine` asserts "almost certainly held by the official WHOOP app", which
+ /// only an auth refusal supports.
+ func testTheSuppressionEpitaphAssertsNoCause() {
+ let e = BondRefusalGiveUp.helloSuppressedEpitaph(refusals: 5, opaqueId: "abcd1234")
+ XCTAssertTrue(e.contains("[abcd1234]"))
+ XCTAssertTrue(e.contains("5x in a row"))
+ XCTAssertFalse(e.contains("almost certainly"))
+ XCTAssertFalse(e.contains("\u{2014}")) // project rule: no em-dash in these lines
+ }
+
+ // MARK: - the keep-alive gate
+
+ /// The defect this gate exists for: the timer used to require the genuine encrypted bond, which a
+ /// suppressed 5/MG never reaches — so the liveness watchdog was switched off for exactly the link the
+ /// suppression keeps up for hours, and a stalled stream would have had nothing to bounce it.
+ func testAStreamingSuppressedFiveMGReachesTheWatchdog() {
+ XCTAssertTrue(keepAliveMayRun(connected: true, didBond: false, bonded: true, family: .whoop5))
+ }
+
+ /// Before HR arrives there is nothing to watch, so starting the timer early at the suppression point
+ /// is harmless — the gate simply returns false until the stream marks the link established (#8).
+ func testItStaysShutUntilTheStreamActuallyStarts() {
+ XCTAssertFalse(keepAliveMayRun(connected: true, didBond: false, bonded: false, family: .whoop5))
+ }
+
+ /// Narrower than the Android twin on purpose: only a 5/MG is admitted unbonded. A 4.0 reaching here
+ /// without a bond would be a real fault, not a suppressed link, and must not be kept alive.
+ func testOnlyAFiveMGIsAdmittedWithoutABond() {
+ for family in DeviceFamily.allCases where family != .whoop5 {
+ XCTAssertFalse(keepAliveMayRun(connected: true, didBond: false, bonded: true, family: family),
+ "\(family) must not run the keep-alive unbonded")
+ }
+ }
+
+ func testABondedLinkAlwaysRunsAndADroppedOneNever() {
+ for family in DeviceFamily.allCases {
+ XCTAssertTrue(keepAliveMayRun(connected: true, didBond: true, bonded: true, family: family))
+ XCTAssertFalse(keepAliveMayRun(connected: false, didBond: true, bonded: true, family: family))
+ }
+ }
+
+ // MARK: - giveUpThresholdFor
+
+ /// The Kotlin twin is `HelloSuppressionTest."an unanswered handshake gives up sooner than an auth
+ /// refusal"`. Both numbers matter: 5 keeps the auth branch's patience, 3 is the unanswered branch.
+ func testUnansweredHandshakeGivesUpSoonerThanAuthRefusal() {
+ XCTAssertEqual(5, giveUpThresholdFor(authRefusal: true, pauseThreshold: 5))
+ XCTAssertEqual(3, giveUpThresholdFor(authRefusal: false, pauseThreshold: 5))
+ // Above the hint threshold, so a PERSISTED verdict still keeps a cycle of margin.
+ XCTAssertGreaterThan(unansweredGiveUpThreshold, 2)
+ }
+
+ /// Patience and treatment read the SAME refusal. Keyed apart they could disagree — pausing on a branch
+ /// that waited the suppression count, or vice versa. Kotlin twin: "the threshold and the treatment
+ /// read the same refusal".
+ func testThresholdAndTreatmentReadTheSameRefusal() {
+ for auth in [true, false] {
+ let suppresses = giveUpSuppressesHello(authRefusal: auth)
+ let threshold = giveUpThresholdFor(authRefusal: auth, pauseThreshold: 5)
+ XCTAssertEqual(suppresses, threshold == unansweredGiveUpThreshold)
+ }
+ }
+
+}
diff --git a/StrandTests/HexPayloadRedactionTests.swift b/StrandTests/HexPayloadRedactionTests.swift
new file mode 100644
index 0000000000..788ca799c2
--- /dev/null
+++ b/StrandTests/HexPayloadRedactionTests.swift
@@ -0,0 +1,84 @@
+import XCTest
+@testable import Strand
+
+/// #1833: a WHOOP serial that arrives as HEX rather than as text.
+///
+/// Every rule in `LiveState.redactPii` matches an identifier written as characters, so none of them can
+/// see one encoded as hex. On a 5/MG, event 109 carries the strap serial as plain ASCII inside its
+/// payload, and this side dumps hex in several places — `frame=` (the clock diagnostic), `[raw …]` and
+/// `(raw …)` (the alarm readback), and the #900 whole-frame dump. The serial went into the log we ask
+/// people to attach to public issues while the MAC rule kept firing, so the line still looked redacted.
+///
+/// Twin of the Kotlin `PiiRedactionTest` cases; the payload below is the real one from the capture on
+/// #1825, which is how the leak was found.
+final class HexPayloadRedactionTests: XCTestCase {
+
+ /// `…WBB5AP0539852…` — a real serial, ASCII inside the payload bytes.
+ private let payloadWithSerial = "142e1c0001d36e3d1c12a3574242354150303533393835320000"
+ private let serialAsHex = "4242354150303533393835"
+
+ func testSerialInsideAHexPayloadIsMasked() {
+ let out = LiveState.redactPii("[event] 0x6D(109) payload=\(payloadWithSerial)")
+ XCTAssertFalse(out.contains(serialAsHex), "serial must not survive as hex: \(out)")
+ XCTAssertTrue(out.contains("142e1c0001d36e3d1c12a3"),
+ "the non-serial bytes are the reason the dump exists: \(out)")
+ }
+
+ /// Not keyed on a label: this side writes the same dumps four different ways.
+ func testMaskedUnderEveryLabelThisSideUses() {
+ for line in ["frame=\(payloadWithSerial)",
+ "[raw \(payloadWithSerial)]",
+ "(raw \(payloadWithSerial))",
+ "raw frame (#900) \(payloadWithSerial)",
+ payloadWithSerial] {
+ XCTAssertFalse(LiveState.redactPii(line).contains(serialAsHex),
+ "serial survived under this label: \(line)")
+ }
+ }
+
+ /// The run regex matches consecutive hex characters, so a dump abutting other hex-valid text gives
+ /// an ODD-length match. Bailing on that returned the serial unredacted while Kotlin masked it — the
+ /// two halves of one rule must not disagree about which lines are safe.
+ func testOddLengthRunStillMasksTheSerial() {
+ let out = LiveState.redactPii("payload=\(payloadWithSerial)f") // 1 trailing half-byte
+ XCTAssertFalse(out.contains(serialAsHex), "odd-length run left the serial exposed: \(out)")
+ }
+
+ /// The Kotlin rule is a regex over the printable projection (`[A-Za-z][0-9A-Za-z]{8,}`), so it finds
+ /// a serial ANYWHERE inside an alphanumeric run. Testing only the run's first byte left the serial
+ /// exposed when a digit preceded it with no separator — masked on Android, printed on Apple.
+ func testSerialIsMaskedEvenWhenDigitsPrecedeItInTheSameRun() {
+ // "0123" + "WBB5AP0539852" as one unbroken alphanumeric run, then a NUL to close it.
+ let hex = "3031323357424235415030353339383532" + "00" + "0000000000000000"
+ let out = LiveState.redactPii("payload=\(hex)")
+ XCTAssertFalse(out.contains(serialAsHex), "digit-prefixed run left the serial exposed: \(out)")
+ XCTAssertTrue(out.contains("30313233"), "the non-serial digits must survive: \(out)")
+ }
+
+ func testPayloadWithNoAsciiRunIsUntouched() {
+ // The charging payloads from the same capture carry no ASCII run — they must pass through whole.
+ for p in ["707d0000707d0000", "b87e0000b87e0000", "0000000000000000"] {
+ XCTAssertEqual(LiveState.redactPii("payload=\(p)"), "payload=\(p)")
+ }
+ }
+
+ /// The existing text rules must keep working — the hex pass runs first and must not eat them.
+ func testTextRulesStillApply() {
+ XCTAssertEqual(LiveState.redactPii("connecting to A1:B2:C3:D4:E5:F6"),
+ "connecting to A1:••:••:••:••:F6")
+ XCTAssertTrue(LiveState.redactPii("Discovered WHOOP 4A2B9C1D").contains(""))
+ }
+
+ /// A vendor/service UUID's longest contiguous hex run is 12 characters, below the 16 the rule needs,
+ /// so a UUID cannot be partially masked into something unreadable.
+ func testServiceUuidIsNotTouched() {
+ let uuid = "61080003-8d6d-82b8-614a-1c8cb0f8dcc6"
+ XCTAssertTrue(LiveState.redactPii("Notify active \(uuid)").contains("8d6d"))
+ }
+
+ /// Odd-length or non-hex content returns unchanged rather than throwing: a redactor that throws
+ /// withholds the entire line.
+ func testMalformedHexIsLeftAlone() {
+ XCTAssertEqual(LiveState.redactPii("payload=zzzzzzzzzzzzzzzzzz"), "payload=zzzzzzzzzzzzzzzzzz")
+ }
+}
diff --git a/StrandTests/HrTraceTests.swift b/StrandTests/HrTraceTests.swift
new file mode 100644
index 0000000000..490057200d
--- /dev/null
+++ b/StrandTests/HrTraceTests.swift
@@ -0,0 +1,181 @@
+import XCTest
+
+/// Swift twin of the Kotlin `HrTraceTest` (#1957).
+///
+/// These exist to keep the two platforms reading the same heart, not merely to check Swift. Every case
+/// below has a Kotlin counterpart asserting the same thing, so a change to the retention rule, the tick
+/// choices or the normalisation that lands on one side alone fails here.
+///
+/// The degenerate cases carry the weight, because they are ordinary on a home screen rather than exotic:
+/// a widget added mid-afternoon has one point, and a resting arm holds one bpm for minutes.
+final class HrTraceTests: XCTestCase {
+
+ private func series(_ pairs: [(Int64, Int)]) -> [HrPoint] {
+ pairs.map { HrPoint(ts: $0.0, bpm: $0.1) }
+ }
+
+ // MARK: retention
+
+ func testOnePointPerMinuteBucketNewestWins() {
+ var s = HrTrace.append([], ts: 600, bpm: 60)
+ s = HrTrace.append(s, ts: 620, bpm: 64) // same bucket
+ s = HrTrace.append(s, ts: 660, bpm: 70) // next bucket
+ XCTAssertEqual(s.count, 2)
+ XCTAssertEqual(s[0].bpm, 64) // newest within the bucket won
+ XCTAssertEqual(s[0].ts, 600) // stamped at the bucket, not the sample
+ XCTAssertEqual(s[1].bpm, 70)
+ }
+
+ /// The headline number is the latest bpm; a trace ending on an average would disagree with it.
+ func testLastPointEqualsLatestReading() {
+ var s: [HrPoint] = []
+ for i in 0...5 { s = HrTrace.append(s, ts: 600 + Int64(i) * 60, bpm: 60 + i) }
+ XCTAssertEqual(HrTrace.stats(s)?.latest, 65)
+ XCTAssertEqual(s.last?.bpm, 65)
+ }
+
+ func testReadingsOlderThanTheWindowFallOff() {
+ let now: Int64 = 100_000
+ let s = series([(now - HrTrace.windowSec - 60, 50), (now - 60, 70)])
+ let kept = HrTrace.prune(s, nowSec: now)
+ XCTAssertEqual(kept.count, 1)
+ XCTAssertEqual(kept[0].bpm, 70)
+ }
+
+ /// A clock that jumps backwards must not grow the series without bound.
+ func testPointCapHoldsUnderABackwardsClock() {
+ var s: [HrPoint] = []
+ for i in 0..<(HrTrace.maxPoints + 50) {
+ s = HrTrace.append(s, ts: Int64(i) * 60, bpm: 60, nowSec: 0)
+ }
+ XCTAssertLessThanOrEqual(s.count, HrTrace.maxPoints)
+ }
+
+ func testNonPositiveBpmIsNotRecorded() {
+ XCTAssertTrue(HrTrace.append([], ts: 600, bpm: 0).isEmpty)
+ }
+
+ // MARK: geometry
+
+ func testPointsSpanTheBoxAndFlipBpmUpward() {
+ let pts = HrTrace.points(series([(0, 60), (60, 80)]), width: 100, height: 50)
+ XCTAssertEqual(pts[0].x, 0, accuracy: 0.001)
+ XCTAssertEqual(pts[1].x, 100, accuracy: 0.001)
+ XCTAssertEqual(pts[0].y, 50, accuracy: 0.001) // the LOW bpm sits at the BOTTOM
+ XCTAssertEqual(pts[1].y, 0, accuracy: 0.001)
+ }
+
+ /// A resting arm holds one bpm for minutes. Zero range must not divide, and must not read as a
+ /// flatlined or maxed-out heart, so the line runs along the middle.
+ func testFlatSeriesDrawsDownTheMiddle() {
+ let pts = HrTrace.points(series([(0, 60), (60, 60), (120, 60)]), width: 90, height: 40)
+ XCTAssertEqual(pts.count, 3)
+ for p in pts { XCTAssertEqual(p.y, 20, accuracy: 0.001) }
+ }
+
+ /// A widget added mid-afternoon has exactly one point until the next minute ticks.
+ func testSinglePointSitsAtTheLeftEdge() {
+ let pts = HrTrace.points(series([(600, 61)]), width: 100, height: 50)
+ XCTAssertEqual(pts.count, 1)
+ XCTAssertEqual(pts[0].x, 0, accuracy: 0.001)
+ XCTAssertFalse(pts[0].y.isNaN)
+ }
+
+ func testEmptySeriesOrZeroBoxDrawsNothing() {
+ XCTAssertTrue(HrTrace.points([], width: 100, height: 50).isEmpty)
+ XCTAssertTrue(HrTrace.points(series([(0, 60)]), width: 0, height: 50).isEmpty)
+ XCTAssertTrue(HrTrace.points(series([(0, 60)]), width: 100, height: 0).isEmpty)
+ }
+
+ // MARK: ticks
+
+ func testBpmTicksRunMaxMiddleMin() {
+ XCTAssertEqual(HrTrace.bpmTicks(HrTrace.Stats(min: 59, max: 84, latest: 69)), [84, 72, 59])
+ }
+
+ /// Three copies of one number is the honest reading of a steady heart. The VIEW declines to draw a
+ /// scale in that case; the rule stays here so both platforms agree on what the labels would be.
+ func testFlatSeriesLabelsOneNumberThreeTimes() {
+ XCTAssertEqual(HrTrace.bpmTicks(HrTrace.Stats(min: 60, max: 60, latest: 60)), [60, 60, 60])
+ }
+
+ func testTimeTicksAnchorToTheDataNotTheWindow() {
+ XCTAssertEqual(HrTrace.timeTicks(series([(1_000, 60), (1_600, 62), (2_200, 64)])),
+ [1_000, 1_600, 2_200])
+ }
+
+ func testTimeTicksDegradeWithoutEnoughSpan() {
+ XCTAssertEqual(HrTrace.timeTicks([]), [])
+ XCTAssertEqual(HrTrace.timeTicks(series([(600, 60)])), [600])
+ XCTAssertEqual(HrTrace.timeTicks(series([(600, 60), (601, 61)])), [600, 601])
+ }
+
+ func testStatsOverAnEmptySeriesIsNil() {
+ XCTAssertNil(HrTrace.stats([]))
+ }
+
+ // MARK: the publish gate that keeps the trace alive
+
+ /// `renderedContentChanged` compares bpm, not history, so a steady heart produced no publish and
+ /// therefore no new point — the trace would stop advancing at rest and prune to empty. This is the
+ /// rule that asks for a write anyway, and it must fire exactly when a new minute bucket opens.
+ func testTraceNeedsPointWhenTheBucketRolls() {
+ let base = Date(timeIntervalSince1970: 1_000_000)
+ var snap = WidgetSnapshot(recovery: nil, bpm: 70, batteryPct: nil, bonded: true, updated: base)
+ snap.hrSeries = [HrPoint(ts: Int64(base.timeIntervalSince1970), bpm: 70)]
+
+ XCTAssertFalse(WidgetSnapshot.traceNeedsPoint(previous: snap, bpm: 70, now: base.addingTimeInterval(5)),
+ "same bucket needs nothing")
+ XCTAssertTrue(WidgetSnapshot.traceNeedsPoint(previous: snap, bpm: 70, now: base.addingTimeInterval(61)),
+ "a new bucket wants its point even though bpm is unchanged")
+ }
+
+ func testTraceNeedsNoPointWithoutAReading() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ XCTAssertFalse(WidgetSnapshot.traceNeedsPoint(previous: nil, bpm: nil, now: now))
+ XCTAssertFalse(WidgetSnapshot.traceNeedsPoint(previous: nil, bpm: 0, now: now))
+ XCTAssertTrue(WidgetSnapshot.traceNeedsPoint(previous: nil, bpm: 70, now: now),
+ "no stored series at all means the first reading is wanted")
+ }
+
+
+ // MARK: the age rule on the headline number
+
+ /// Twin of the Kotlin `HrDisplay`. Without it the card contradicted itself once the trace began
+ /// pruning: an empty chart under a confident number, from a reading hours old.
+ func testAFreshReadingIsShownAndNotDimmed() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let r = HrDisplay.resolve(bpm: 70, newestPointTs: Int64(now.timeIntervalSince1970) - 30, now: now)
+ XCTAssertEqual(r.bpm, 70)
+ XCTAssertFalse(r.stale)
+ }
+
+ func testACarriedReadingIsShownButDimmed() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let ts = Int64(now.timeIntervalSince1970 - HrDisplay.liveWindow - 30)
+ let r = HrDisplay.resolve(bpm: 70, newestPointTs: ts, now: now)
+ XCTAssertEqual(r.bpm, 70)
+ XCTAssertTrue(r.stale, "past the live window it is carried over, not current")
+ }
+
+ func testAnAncientReadingIsDroppedEntirely() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let ts = Int64(now.timeIntervalSince1970 - HrDisplay.staleCap - 60)
+ XCTAssertNil(HrDisplay.resolve(bpm: 70, newestPointTs: ts, now: now).bpm)
+ }
+
+ /// A widget added this minute has a reading and an empty series. Treating that as stale would blank
+ /// a widget at the moment it started working.
+ func testAFirstReadingWithNoTraceYetIsShown() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ let r = HrDisplay.resolve(bpm: 70, newestPointTs: nil, now: now)
+ XCTAssertEqual(r.bpm, 70)
+ XCTAssertFalse(r.stale)
+ }
+
+ func testNoReadingShowsNothing() {
+ let now = Date(timeIntervalSince1970: 1_000_000)
+ XCTAssertNil(HrDisplay.resolve(bpm: nil, newestPointTs: nil, now: now).bpm)
+ XCTAssertNil(HrDisplay.resolve(bpm: 0, newestPointTs: nil, now: now).bpm)
+ }
+}
diff --git a/StrandTests/IntelligenceSleepDetectNoNightTests.swift b/StrandTests/IntelligenceSleepDetectNoNightTests.swift
index 8097f5588c..80ea9a6f2d 100644
--- a/StrandTests/IntelligenceSleepDetectNoNightTests.swift
+++ b/StrandTests/IntelligenceSleepDetectNoNightTests.swift
@@ -1,4 +1,5 @@
import XCTest
+import StrandAnalytics
@testable import Strand
/// Pins the "HR tracked but no sleep" diagnostic (#1244). When a day clears the >=200-HR gate yet the
@@ -18,25 +19,89 @@ final class IntelligenceSleepDetectNoNightTests: XCTestCase {
// gate the night → nothing stages. `window=54h` is the past-day span (30 h back → next midnight).
let line = IE.sleepDetectNoNightLogLine(
day: "2026-08-11", hrCount: 41230, rrCount: 0, respCount: 880,
- gravCount: 0, stepCount: 12, providedCount: 0, windowHours: 54)
+ gravCount: 0, stepCount: 12, providedCount: 0, windowHours: 54, skinCount: 0)
XCTAssertEqual(line,
"sleep-detect day=2026-08-11 NO-NIGHT hr=41230 rr=0 resp=880 "
- + "grav=0 steps=12 provided=0 window=54h")
+ // reason=no-motion is the point of this fixture: grav=0 means the stager has no HR-only
+ // fallback, so no quantity of HR could have staged a night. That is a strap capability limit,
+ // and it wants a different follow-up from a night that had motion and still staged nothing.
+ + "grav=0 skin=0 steps=12 provided=0 window=54h reason=no-motion")
}
func testTodayWindowIs48h() {
// Today's read caps at dayStart+18h (vs a past day's next-midnight), so the whole span is 48 h.
let line = IE.sleepDetectNoNightLogLine(
day: "2026-08-12", hrCount: 5000, rrCount: 900, respCount: 300,
- gravCount: 4, stepCount: 0, providedCount: 0, windowHours: 48)
+ gravCount: 4, stepCount: 0, providedCount: 0, windowHours: 48, skinCount: 0)
XCTAssertTrue(line.contains("window=48h"), line)
+ // The other branch: motion WAS present and staging still produced nothing, which is the case
+ // worth investigating rather than a capability limit.
+ XCTAssertTrue(line.contains("reason=staged-none"), line)
}
func testLineCarriesNoEmDash() {
// House style: never an em-dash in shared text.
let line = IE.sleepDetectNoNightLogLine(
day: "2026-08-11", hrCount: 1, rrCount: 1, respCount: 1,
- gravCount: 1, stepCount: 1, providedCount: 1, windowHours: 54)
+ gravCount: 1, stepCount: 1, providedCount: 1, windowHours: 54, skinCount: 1)
XCTAssertFalse(line.contains("—"))
}
+
+ /// The signal this line was missing. Gravity is a PLAIN read with no truncation counter, so a night
+ /// clipped of its newest motion staged badly and said nothing about why — and `grav=192698` reads as
+ /// healthy until you know it is 96% of a cap. A read that comes back AT the limit is truncated, which
+ /// is what `full.count >= limit` means everywhere else here.
+ func testAStreamAtItsReadCapIsNamed() {
+ let line = IntelligenceEngine.sleepDetectNoNightLogLine(
+ day: "2026-09-06", hrCount: 1000, rrCount: 1000, respCount: 0,
+ gravCount: StreamReadCap.gravity, stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 0)
+ XCTAssertTrue(line.contains("atCap=grav"), line)
+ }
+
+ /// HR and R-R at their caps produce NO marker, and that is deliberate rather than an oversight.
+ ///
+ /// Both arrive through `SlidingStreamWindow.rows`, which returns a SLICE of a read spanning more than
+ /// this night, so a truncated spliced window still hands back a count under the cap — the marker would
+ /// miss the very case it claims to catch. Their exact truncation count is printed once per pass by
+ /// `WindowedStreamPlan.logLine` instead. Pinned so the omission is not "fixed" back in.
+ func testHrAndRrAtTheirCapsCarryNoMarker() {
+ let line = IntelligenceEngine.sleepDetectNoNightLogLine(
+ day: "2026-09-06", hrCount: StreamReadCap.hr, rrCount: StreamReadCap.rr, respCount: 0,
+ gravCount: 10, stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 0)
+ XCTAssertFalse(line.contains("atCap"), line)
+ }
+
+ /// A healthy night says nothing extra — the marker only appears when something actually clipped.
+ func testANightUnderTheCapsCarriesNoMarker() {
+ let line = IntelligenceEngine.sleepDetectNoNightLogLine(
+ day: "2026-09-06", hrCount: 192_698, rrCount: 136_285, respCount: 0,
+ gravCount: 192_698, stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 0)
+ XCTAssertFalse(line.contains("atCap"), line)
+ }
+
+ /// The field capture that motivated the caps: 192,698 gravity rows was 96% of the OLD 200,000 limit
+ /// and silent. Under the caps this ships with, the same night is comfortably clear.
+ func testTheMeasuredFieldNightIsClearOfTheCaps() {
+ XCTAssertLessThan(192_698, StreamReadCap.gravity)
+ }
+
+ /// The count that could not be measured. Skin temp only appears in a Test Centre "Night" line, which
+ /// fires when a session EXISTS — so on the nights being triaged, the ones with no sleep at all, its
+ /// volume was invisible.
+ func testTheLineReportsTheSkinSampleCount() {
+ let line = IntelligenceEngine.sleepDetectNoNightLogLine(
+ day: "2026-09-06", hrCount: 1000, rrCount: 900, respCount: 0, gravCount: 0,
+ stepCount: 0, providedCount: 0, windowHours: 54, skinCount: 4242)
+ XCTAssertTrue(line.contains("skin=4242"), line)
+ }
+
+ /// Skin is the stream whose density was never measured, and it was the one `atCap` did not cover
+ /// when the marker was first written — so a clipped skin read printed a bare count and no warning.
+ func testSkinAtItsReadCapIsNamed() {
+ let line = IntelligenceEngine.sleepDetectNoNightLogLine(
+ day: "2026-09-06", hrCount: 10, rrCount: 10, respCount: 0, gravCount: 10,
+ stepCount: 0, providedCount: 0, windowHours: 54, skinCount: StreamReadCap.skin)
+ XCTAssertTrue(line.contains("atCap=skin"), line)
+ }
+
}
diff --git a/StrandTests/KeyMetricPrefsDecodeTests.swift b/StrandTests/KeyMetricPrefsDecodeTests.swift
index 99b5af1148..c6be7ce157 100644
--- a/StrandTests/KeyMetricPrefsDecodeTests.swift
+++ b/StrandTests/KeyMetricPrefsDecodeTests.swift
@@ -44,4 +44,17 @@ final class KeyMetricPrefsDecodeTests: XCTestCase {
XCTAssertEqual(KeyMetricPrefs.decodeEnabled(" "), KeyMetric.defaultOrder)
XCTAssertEqual(KeyMetricPrefs.decodeEnabled(""), KeyMetric.defaultOrder)
}
+
+ /// Queue 11c follow-up (2026-08-24): `.skinTemp` is a NEW persisted case (Skin Temp was already a
+ /// "Your Cards" `DashboardCard` option, never a Key Metrics one). Pins the two contract points that
+ /// matter for adding a case without disturbing anyone's saved layout: the raw token round-trips
+ /// ("skinTemp", byte-identical to Kotlin's `KeyMetric.SKIN_TEMP.raw`), and it does NOT join
+ /// `defaultOrder` — a fresh install's default (and an existing user's saved layout) stays
+ /// byte-identical to before this case existed.
+ func testSkinTempRawTokenRoundTripsAndIsNotInDefaultOrder() {
+ XCTAssertEqual(KeyMetric(rawValue: "skinTemp"), .skinTemp)
+ XCTAssertEqual(KeyMetric.skinTemp.rawValue, "skinTemp")
+ XCTAssertFalse(KeyMetric.defaultOrder.contains(.skinTemp))
+ XCTAssertFalse(KeyMetricPrefs.decodeEnabled("").contains(.skinTemp))
+ }
}
diff --git a/StrandTests/LastSyncAttributionTests.swift b/StrandTests/LastSyncAttributionTests.swift
new file mode 100644
index 0000000000..df4add5e28
--- /dev/null
+++ b/StrandTests/LastSyncAttributionTests.swift
@@ -0,0 +1,83 @@
+import XCTest
+@testable import Strand
+
+/// Pins last-sync attribution. Kotlin twin: `LastSyncAttributionTest`.
+///
+/// Reported three times before it was believed, which is the part worth keeping: the number was
+/// plausible, so the reading was treated as mistaken rather than the label. The capture had
+/// "Last sync: 4d ago" beside zero banked rows for the active 5/MG, and a 4.0 last seen three days
+/// earlier — the timestamp on the 5/MG's screen belonged to the other strap.
+final class LastSyncAttributionTests: XCTestCase {
+
+ func testThisStrapsOwnStampAlwaysWins() {
+ XCTAssertEqual(LastSyncAttribution.resolve(perDevice: 500, legacyGlobal: 900, pairedCount: 1), 500)
+ XCTAssertEqual(LastSyncAttribution.resolve(perDevice: 500, legacyGlobal: 900, pairedCount: 3), 500)
+ }
+
+ /// The single-strap upgrade path. The global key is unattributed, but with one strap paired there is
+ /// only one strap it can have come from — so it reads correctly across the upgrade instead of
+ /// resetting to "never" for everyone.
+ func testTheLegacyGlobalIsHonouredOnlyWhenOneStrapCouldHaveWrittenIt() {
+ XCTAssertEqual(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 900, pairedCount: 1), 900)
+ XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 900, pairedCount: 2))
+ }
+
+ /// THE case from the capture. Two straps, and the active one has never synced: the honest answer is
+ /// "never", not the other strap's timestamp. Not a degraded answer — the correct one.
+ func testAStrapThatHasNeverSyncedSaysSoEvenWhenAnotherHas() {
+ XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 1_787_000_000, pairedCount: 2))
+ }
+
+ /// Zero is "never recorded", not a timestamp — the defaults API returns 0.0 for a missing Double, so
+ /// treating it as a value would date every strap to 1970.
+ func testZeroAndNilAreBothAbsent() {
+ XCTAssertNil(LastSyncAttribution.resolve(perDevice: 0, legacyGlobal: 0, pairedCount: 1))
+ XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: nil, pairedCount: 1))
+ XCTAssertEqual(LastSyncAttribution.resolve(perDevice: 0, legacyGlobal: 900, pairedCount: 1), 900)
+ }
+
+ /// A zero-paired registry must not license the global. The count is the evidence that exactly one
+ /// strap could have written it; "no straps" is not that evidence, and treating it as such would let a
+ /// failed registry read resurrect the bug.
+ func testAnEmptyRegistryDoesNotLicenseTheGlobal() {
+ XCTAssertNil(LastSyncAttribution.resolve(perDevice: nil, legacyGlobal: 900, pairedCount: 0))
+ }
+
+ func testThePrefKeyIsPerDeviceAndCaseInsensitive() {
+ XCTAssertEqual(LastSyncAttribution.prefKey(peripheralId: "F1:D4:F7:24:53:DE"),
+ "noop.lastSyncAt.f1:d4:f7:24:53:de")
+ XCTAssertEqual(LastSyncAttribution.prefKey(peripheralId: "f1:d4:f7:24:53:de"),
+ LastSyncAttribution.prefKey(peripheralId: "F1:D4:F7:24:53:DE"))
+ XCTAssertNil(LastSyncAttribution.prefKey(peripheralId: nil))
+ XCTAssertNil(LastSyncAttribution.prefKey(peripheralId: " "))
+ }
+
+ /// It must not collide with the firmware key, which is built the same way from the same identifier.
+ func testItDoesNotCollideWithTheFirmwareKeyForTheSameStrap() {
+ let id = "f1:d4:f7:24:53:de"
+ XCTAssertNotEqual(LastSyncAttribution.prefKey(peripheralId: id),
+ FirmwareAttribution.prefKey(peripheralId: id))
+ }
+
+ /// The #57 write-health pair had the identical defect one line below in the same capture: "rows last
+ /// landed 4d ago" against a strap whose own row count was zero. Both halves are scoped, because they
+ /// are read as a pair — "stalled more recently than ok" is the alarm — and scoping only one would
+ /// compare this strap's stall against another strap's success.
+ func testTheWriteHealthPairIsScopedAndItsHalvesStayDistinct() {
+ let id = "f1:d4:f7:24:53:de"
+ XCTAssertEqual(LastSyncAttribution.writeHealthPrefKey(peripheralId: id, kind: "lastWriteOkAt"),
+ "sync.lastWriteOkAt.f1:d4:f7:24:53:de")
+ XCTAssertNotEqual(LastSyncAttribution.writeHealthPrefKey(peripheralId: id, kind: "lastWriteOkAt"),
+ LastSyncAttribution.writeHealthPrefKey(peripheralId: id, kind: "lastWriteStalledAt"))
+ XCTAssertNil(LastSyncAttribution.writeHealthPrefKey(peripheralId: nil, kind: "lastWriteOkAt"))
+ XCTAssertNil(LastSyncAttribution.writeHealthPrefKey(peripheralId: " ", kind: "lastWriteOkAt"))
+ }
+
+ /// Two straps must never share a write-health key, or one strap's successful offload would clear the
+ /// alarm raised by another strap's stall.
+ func testTwoStrapsGetDifferentWriteHealthKeys() {
+ XCTAssertNotEqual(
+ LastSyncAttribution.writeHealthPrefKey(peripheralId: "aa:bb:cc:dd:ee:ff", kind: "lastWriteOkAt"),
+ LastSyncAttribution.writeHealthPrefKey(peripheralId: "ff:ee:dd:cc:bb:aa", kind: "lastWriteOkAt"))
+ }
+}
diff --git a/StrandTests/LiquidTodayTileValueTests.swift b/StrandTests/LiquidTodayTileValueTests.swift
new file mode 100644
index 0000000000..7e019565ee
--- /dev/null
+++ b/StrandTests/LiquidTodayTileValueTests.swift
@@ -0,0 +1,33 @@
+import XCTest
+@testable import Strand
+
+/// Pins the Key Metrics tile's value/unit join (#492, and the `–%` it left behind).
+///
+/// The reported symptom was an empty Strain tile reading `–%`: the placeholder kept its unit, so "no data"
+/// rendered as something that parses like a negative percentage. Android has always suppressed the unit on
+/// its no-data sentinel; these pin the iOS twin so the two cannot drift apart again.
+final class LiquidTodayTileValueTests: XCTestCase {
+
+ /// The reported bug, exactly: a missing metric must not carry a unit.
+ func testNoValuePlaceholderDropsItsUnit() {
+ XCTAssertEqual(LiquidTodayView.tileDisplayValue(LiquidTodayView.noValueDash, unit: "%"),
+ LiquidTodayView.noValueDash)
+ XCTAssertEqual(LiquidTodayView.tileDisplayValue(LiquidTodayView.noValueDash, unit: "kcal"),
+ LiquidTodayView.noValueDash)
+ }
+
+ /// A real value still gets its unit, and `%` binds tight where the others take a space — the existing
+ /// convention, unchanged by the guard above.
+ func testAValueKeepsItsUnitAndSpacing() {
+ XCTAssertEqual(LiquidTodayView.tileDisplayValue("93", unit: "%"), "93%")
+ XCTAssertEqual(LiquidTodayView.tileDisplayValue("1272", unit: "kcal"), "1272 kcal")
+ }
+
+ /// Effort passes an EMPTY unit now (it is a 0–100 / 0–21 load index, not a percentage), so the value
+ /// must come through untouched on both the present and the absent path.
+ func testAnEmptyUnitIsLeftAlone() {
+ XCTAssertEqual(LiquidTodayView.tileDisplayValue("38.9", unit: ""), "38.9")
+ XCTAssertEqual(LiquidTodayView.tileDisplayValue(LiquidTodayView.noValueDash, unit: ""),
+ LiquidTodayView.noValueDash)
+ }
+}
diff --git a/StrandTests/NightDetailDebtFormattingTests.swift b/StrandTests/NightDetailDebtFormattingTests.swift
new file mode 100644
index 0000000000..f7612ca5fc
--- /dev/null
+++ b/StrandTests/NightDetailDebtFormattingTests.swift
@@ -0,0 +1,33 @@
+import XCTest
+import SwiftUI
+@testable import StrandDesign
+@testable import Strand
+
+final class NightDetailDebtFormattingTests: XCTestCase {
+ func testTenMinuteDebtBoundary() {
+ XCTAssertEqual(nightDetailDebtCaption(9.9), String(localized: "On target"))
+ assertSameResolvedColor(nightDetailDebtColor(9.9), StrandPalette.statusPositive)
+
+ XCTAssertEqual(nightDetailDebtCaption(10.0), String(localized: "Below need"))
+ assertSameResolvedColor(nightDetailDebtColor(10.0), StrandPalette.statusWarning)
+ }
+
+ func testImportedDebtAboveBoundaryRemainsDebt() {
+ XCTAssertEqual(nightDetailDebtCaption(12.5), String(localized: "Below need"))
+ assertSameResolvedColor(nightDetailDebtColor(12.5), StrandPalette.statusWarning)
+ }
+
+ private func assertSameResolvedColor(
+ _ actual: SwiftUI.Color,
+ _ expected: SwiftUI.Color,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ let actualRGBA = actual.rgbaComponents
+ let expectedRGBA = expected.rgbaComponents
+ XCTAssertEqual(actualRGBA.r, expectedRGBA.r, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actualRGBA.g, expectedRGBA.g, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actualRGBA.b, expectedRGBA.b, accuracy: 0.001, file: file, line: line)
+ XCTAssertEqual(actualRGBA.a, expectedRGBA.a, accuracy: 0.001, file: file, line: line)
+ }
+}
diff --git a/StrandTests/OnboardingUnitsPickerTests.swift b/StrandTests/OnboardingUnitsPickerTests.swift
index 5ad0bab4ef..05c15ea5f0 100644
--- a/StrandTests/OnboardingUnitsPickerTests.swift
+++ b/StrandTests/OnboardingUnitsPickerTests.swift
@@ -14,6 +14,7 @@ final class OnboardingUnitsPickerTests: XCTestCase {
/// or the choice made in onboarding wouldn't reach the Weight/Height display.
func testUnitSystemKeyIsTheSharedAppStorageKey() {
XCTAssertEqual(UnitPrefs.systemKey, "units.system")
+ XCTAssertEqual(UnitPrefs.distanceSystemKey, "units.distance")
}
/// The picker tags are the `UnitSystem` rawValues; they must round-trip through the same initializer
diff --git a/StrandTests/OrphanedSamplesLineTests.swift b/StrandTests/OrphanedSamplesLineTests.swift
index efd7a5b82a..f5f49617f8 100644
--- a/StrandTests/OrphanedSamplesLineTests.swift
+++ b/StrandTests/OrphanedSamplesLineTests.swift
@@ -45,4 +45,62 @@ final class OrphanedSamplesLineTests: XCTestCase {
)
XCTAssertTrue(line.contains("'whoop-aa' (50 rows), 'whoop-zz' (50 rows)"))
}
+
+ // MARK: - #1193 wording is for a genuine split — NOT for a second strap's night
+
+ /// Both over-assertions this branch has carried. It must not call a second strap's night a read
+ /// failure — `DayOwnerResolver` hands each day to whichever device holds its data, so samples under
+ /// that id can be perfectly normal. And it must not call the silence expected either: with a 4.0 and
+ /// a 5.0 worn together the active strap can bank nothing because its handshake never completed
+ /// (#1635), which looks identical from here. The line states both halves; this pins that it keeps
+ /// stating both.
+ func testASecondRegisteredStrapsNightStatesTheForkNeverABareVerdict() {
+ let line = DebugDataDiagnostics.orphanedSamplesLine(
+ activeId: "whoop-FD:4A",
+ othersWithSamples: [("my-whoop", 59_304)],
+ otherLiveStrapIds: ["my-whoop"])
+ XCTAssertTrue(line.contains("another registered strap"))
+ XCTAssertTrue(line.contains("'my-whoop' (59304 rows)"))
+ XCTAssertTrue(line.contains("dayOwner"), "must point at the line that settles it")
+ XCTAssertFalse(line.contains("are not being read"), "must not claim a bug")
+ XCTAssertFalse(line.contains("#1193"))
+ // #1635 dual-wear: it may NOT declare the silence normal outright. Wearing a 4.0 and a 5.0
+ // together, the other strap's rows are present while the ACTIVE one banked nothing because its
+ // handshake never completed — and this function cannot tell that from a single-strap night.
+ XCTAssertTrue(line.contains("If you wore BOTH"), "must state the both-straps half")
+ XCTAssertTrue(line.contains("sync is what to check"), "and name the sync as what to check")
+ XCTAssertFalse(line.contains("OWNED by that strap"), "must not assert one-strap ownership")
+ }
+
+ /// An id that is NOT a live registered strap is still the #1193 split — that wording must survive.
+ func testAnUnregisteredIdHoldingTheSamplesIsStillReportedAsASplit() {
+ let line = DebugDataDiagnostics.orphanedSamplesLine(
+ activeId: "whoop-FD:4A",
+ othersWithSamples: [("my-whoop", 59_304)],
+ otherLiveStrapIds: ["whoop-OTHER"])
+ XCTAssertTrue(line.contains("#1193"))
+ XCTAssertTrue(line.contains("are not being read"))
+ }
+
+ /// No registry supplied (the default) keeps every existing caller byte-identical.
+ func testWithoutARegistryTheWordingIsUnchanged() {
+ let withDefault = DebugDataDiagnostics.orphanedSamplesLine(
+ activeId: "whoop-FD:4A", othersWithSamples: [("my-whoop", 59_304)])
+ let explicitEmpty = DebugDataDiagnostics.orphanedSamplesLine(
+ activeId: "whoop-FD:4A", othersWithSamples: [("my-whoop", 59_304)], otherLiveStrapIds: [])
+ XCTAssertEqual(withDefault, explicitEmpty)
+ XCTAssertTrue(withDefault.contains("#1193"))
+ }
+
+ /// Mixed: one id is a live strap, one is not — the live strap explains it, so no bug is claimed.
+ func testALiveStrapAmongTheIdsWinsOverTheSplitWording() {
+ let line = DebugDataDiagnostics.orphanedSamplesLine(
+ activeId: "whoop-FD:4A",
+ othersWithSamples: [("orphan-id", 10), ("my-whoop", 59_304)],
+ otherLiveStrapIds: ["my-whoop"])
+ XCTAssertTrue(line.contains("another registered strap"))
+ XCTAssertTrue(line.contains("'my-whoop' (59304 rows)"))
+ XCTAssertFalse(line.contains("'orphan-id'"),
+ "the orphan id is not the story when a real strap owns the night")
+ }
}
diff --git a/StrandTests/PiiRedactionTests.swift b/StrandTests/PiiRedactionTests.swift
new file mode 100644
index 0000000000..6e9691bda3
--- /dev/null
+++ b/StrandTests/PiiRedactionTests.swift
@@ -0,0 +1,126 @@
+import XCTest
+@testable import Strand
+
+/// The account holder's NAME in the shareable strap log (#445).
+///
+/// WHOOP names a strap "'s Whoop" by default, and the scan path logs that advertised name on
+/// every discovery, so the file we ask people to attach to public issues carried a real person's name.
+/// None of the other rules in `LiveState.redactPii` could see it: they key on MAC shape, a "WHOOP " +
+/// digit serial, or a "whoop-" id.
+///
+/// Twin of the Kotlin `PiiRedactionTest` name cases, same inputs and same expected text, so the two
+/// platforms cannot redact a log differently.
+final class PiiRedactionTests: XCTestCase {
+
+ func testPersonalNameInDiscoveryLineIsRedacted() {
+ XCTAssertEqual(
+ LiveState.redactPii("Discovered Ryan's Whoop (rssi -55) - connecting"),
+ "Discovered 's Whoop (rssi -55) - connecting")
+ }
+
+ /// Apple platforms write U+2019 into default device names, so the straight quote is not enough —
+ /// a straight-quote-only rule would miss this platform's own logs.
+ func testCurlyApostropheNameIsRedacted() {
+ XCTAssertEqual(
+ LiveState.redactPii("Discovered Ryan\u{2019}s Whoop (rssi -55)"),
+ "Discovered \u{2019}s Whoop (rssi -55)")
+ }
+
+ /// The MODEL after the possessive is diagnostic and identifies nobody, so it must survive.
+ func testModelSurvivesNameRedaction() {
+ XCTAssertEqual(LiveState.redactPii("Ryan's WHOOP 4.0"), "'s WHOOP 4.0")
+ }
+
+ /// The documented gap, pinned so it stays visible rather than assumed: ONE token before the
+ /// possessive. A multi-token rule cannot tell a name from surrounding log text.
+ func testMultiTokenNameKeepsTheLeadingToken() {
+ XCTAssertEqual(LiveState.redactPii("Ryan B's Whoop"), "Ryan 's Whoop")
+ }
+
+ /// The rule must not touch ids or ordinary text that merely contain "whoop".
+ func testNameRuleLeavesIdsAndPlainTextAlone() {
+ XCTAssertEqual(LiveState.redactPii("my-whoop and my-whoop-noop"), "my-whoop and my-whoop-noop")
+ XCTAssertEqual(LiveState.redactPii("no pii here"), "no pii here")
+ }
+
+ /// The name never reaches a shared log at all. The sink rule is defence-in-depth; this is the real
+ /// guarantee, and it is an ALLOWLIST so an unanticipated naming shape is dropped by default.
+ func testLogSafeDeviceNameKeepsOnlyTheModel() {
+ XCTAssertEqual(LiveState.logSafeDeviceName("Ryan's Whoop"), " Whoop")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Ryan B's WHOOP 4.0"), " WHOOP 4.0")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Ryan\u{2019}s WHOOP 5.0 MG"), " WHOOP 5.0 MG")
+ }
+
+ /// A fully custom name has no model token to keep, so nothing of it survives.
+ func testLogSafeDeviceNameDropsAWhollyCustomName() {
+ XCTAssertEqual(LiveState.logSafeDeviceName("Dad's spare"), "")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Sarah"), "")
+ }
+
+ /// "We saw no name" and "we removed a name" are different facts to a reader, so the sentinel stays.
+ func testLogSafeDeviceNameKeepsTheNoNameSentinel() {
+ XCTAssertEqual(LiveState.logSafeDeviceName("unknown"), "unknown")
+ XCTAssertEqual(LiveState.logSafeDeviceName(nil), "unknown")
+ XCTAssertEqual(LiveState.logSafeDeviceName(" "), "unknown")
+ }
+
+
+ /// Third-party straps: the MODEL is the diagnostic, so a name carrying no person survives intact.
+ func testLogSafeDeviceNameKeepsAnUnrenamedThirdPartyModel() {
+ XCTAssertEqual(LiveState.logSafeDeviceName("Polar H10"), "Polar H10")
+ XCTAssertEqual(LiveState.logSafeDeviceName("TICKR"), "TICKR")
+ XCTAssertEqual(LiveState.logSafeDeviceName("WHOOP 4.0"), "WHOOP 4.0")
+ }
+
+ /// ...but a renamed one loses the person and keeps the model.
+ func testLogSafeDeviceNameStripsThePersonFromARenamedThirdPartyStrap() {
+ XCTAssertEqual(LiveState.logSafeDeviceName("Ryan's Polar H10"), " Polar H10")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Sarah TICKR"), " TICKR")
+ }
+
+
+ /// The hole a model-code PATTERN opened, pinned shut. "[a-z]{1,4}\\d{1,3}" was meant to admit "H10"
+ /// and admitted "Ryan1" and "Sam99" with it, passing a first name through untouched. Model codes are
+ /// listed one by one for this reason; if a pattern is ever reintroduced, these fail.
+ func testANameWithDigitsIsNotMistakenForAModelCode() {
+ XCTAssertEqual(LiveState.logSafeDeviceName("Ryan1"), "")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Anna2"), "")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Bob12"), "")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Sam99 Whoop"), " Whoop")
+ }
+
+ /// The listed codes still survive, so the tightening did not cost the diagnostic.
+ func testListedModelCodesStillSurvive() {
+ XCTAssertEqual(LiveState.logSafeDeviceName("Polar H10"), "Polar H10")
+ XCTAssertEqual(LiveState.logSafeDeviceName("Polar OH1"), "Polar OH1")
+ }
+
+
+ /// Field evidence (a 2026-09-05 staging log): serials beginning with a LETTER walked straight through
+ /// the old rule, which demanded a digit after "WHOOP ". Both spellings must mask.
+ func testMasksASerialWhateverCharacterItStartsWith() {
+ XCTAssertEqual(LiveState.redactPii("Discovered WHOOP MGB0779473 (rssi -48) - connecting"),
+ "Discovered WHOOP (rssi -48) - connecting")
+ XCTAssertEqual(LiveState.redactPii("Discovered WHOOP 4C1594026 (rssi -63)"),
+ "Discovered WHOOP (rssi -63)")
+ XCTAssertEqual(LiveState.redactPii("WHOOP 5AG0393796"), "WHOOP ")
+ }
+
+ /// The digit requirement earns its keep here. "WHOOP PUFFIN service 1150" is a real diagnostic line
+ /// from the same log, and PUFFIN is six alnum characters - a length-only rule would mask it and
+ /// destroy the meaning of the line.
+ func testDoesNotMaskAWhoopPrefixedWord() {
+ let line = "WHOOP PUFFIN service 1150 detected but unsupported"
+ XCTAssertEqual(LiveState.redactPii(line), line)
+ }
+
+ /// A bare serial with no "WHOOP " in front of it is NOT covered here, and deliberately so: the only
+ /// rule that could catch it keys on shape alone and would mask ordinary prose. Discovery lines -
+ /// where this actually occurred - are handled at the call site by `logSafeDeviceName` instead.
+ func testABareSerialIsLeftToTheCallSiteNotGuessedAtHere() {
+ XCTAssertEqual(LiveState.redactPii("Discovered WBB5BP1174092 (rssi -64)"),
+ "Discovered WBB5BP1174092 (rssi -64)")
+ XCTAssertEqual(LiveState.logSafeDeviceName("WBB5BP1174092"), "")
+ }
+
+}
diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift
new file mode 100644
index 0000000000..f00a0703d2
--- /dev/null
+++ b/StrandTests/RawDataCollectorParityTests.swift
@@ -0,0 +1,57 @@
+import XCTest
+@testable import Strand
+
+/// Feature-level parity guard for the Android and Apple raw-data collectors. The shared oracle lists
+/// every user-visible/session-lifecycle capability and source markers on both implementations.
+final class RawDataCollectorParityTests: XCTestCase {
+ private struct Oracle: Decodable {
+ let schemaVersion: Int
+ let capabilities: [String: Capability]
+ enum CodingKeys: String, CodingKey { case schemaVersion = "schema_version", capabilities }
+ }
+ private struct Capability: Decodable { let swift: [String]; let kotlin: [String] }
+
+ private var repoRoot: URL {
+ URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
+ }
+
+ private func oracleData() throws -> Data {
+ let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "raw_data_collector_parity",
+ withExtension: "json"))
+ return try Data(contentsOf: url)
+ }
+
+ func testAppleSurfaceStillImplementsEveryDeclaredCapability() throws {
+ let oracle = try JSONDecoder().decode(Oracle.self, from: oracleData())
+ XCTAssertEqual(oracle.schemaVersion, 1)
+ let paths = [
+ "Strand/Collect/RawDataSessionStore.swift", "Strand/Collect/Collector.swift",
+ "Strand/BLE/BLEManager.swift", "Strand/Screens/RawDataCollectorView.swift",
+ "Strand/Collect/ImuSessionFileStore.swift",
+ "Packages/WhoopStore/Sources/WhoopStore/Database.swift",
+ "Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift",
+ "Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift",
+ ]
+ let source = try paths.map { try String(contentsOf: repoRoot.appendingPathComponent($0)) }
+ .joined(separator: "\n")
+ for (name, capability) in oracle.capabilities {
+ for marker in capability.swift {
+ XCTAssertTrue(source.contains(marker), "Apple collector lost \(name) marker: \(marker)")
+ }
+ }
+ }
+
+ func testAndroidAndAppleOracleCopiesAreByteIdentical() throws {
+ let android = repoRoot.appendingPathComponent("android/app/src/test/resources/raw_data_collector_parity.json")
+ XCTAssertEqual(try oracleData(), try Data(contentsOf: android),
+ "Raw-data collector parity oracle copies must change together")
+ }
+
+ func testAppleKeepsTheOffSessionRealtimeImuFailSafe() throws {
+ let source = try String(contentsOf: repoRoot.appendingPathComponent("Strand/BLE/BLEManager.swift"))
+ XCTAssertTrue(source.contains("stopUnexpectedRealtimeImu(frame, isOffload: isOffload)"))
+ XCTAssertTrue(source.contains("frame[8] == 43 || frame[8] == 51"))
+ XCTAssertTrue(source.contains("send(.stopRawData, payload: [0x01]"))
+ XCTAssertTrue(source.contains("send(.toggleIMUMode, payload: [0x01, 0x00]"))
+ }
+}
diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift
new file mode 100644
index 0000000000..38c2c54714
--- /dev/null
+++ b/StrandTests/RawDataSessionStoreTests.swift
@@ -0,0 +1,158 @@
+import XCTest
+@testable import Strand
+import WhoopStore
+
+@MainActor
+final class RawDataSessionStoreTests: XCTestCase {
+ private func temporaryDirectory() throws -> URL {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ addTeardownBlock { try? FileManager.default.removeItem(at: url) }
+ return url
+ }
+
+ func testRawSessionLifecycleAndReload() throws {
+ let directory = try temporaryDirectory()
+ let store = RawDataSessionStore(directory: directory)
+ XCTAssertNotNil(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)))
+ store.stop(now: Date(timeIntervalSince1970: 106))
+
+ let session = try XCTUnwrap(store.sessions.first)
+ XCTAssertEqual(session.endedAtMs, 106_000)
+ XCTAssertEqual(session.events.map(\.kind), ["start", "stop"])
+
+ let reloaded = RawDataSessionStore(directory: directory)
+ XCTAssertEqual(reloaded.sessions.first, session)
+ }
+
+ func testMarkersCanBeAddedEditedDeletedAndExported() throws {
+ let store = RawDataSessionStore(directory: try temporaryDirectory())
+ let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)))
+ let marker = try XCTUnwrap(store.addMarker(sessionId: session.id,
+ at: Date(timeIntervalSince1970: 102), type: "issue", text: "Bluetooth off"))
+ store.updateMarker(sessionId: session.id, markerId: try XCTUnwrap(marker.markerId),
+ at: Date(timeIntervalSince1970: 103), type: "moment", text: "Recovered")
+ store.stop(now: Date(timeIntervalSince1970: 106))
+
+ let updated = try XCTUnwrap(store.sessions.first)
+ let saved = try XCTUnwrap(updated.events.first(where: { $0.kind == "marker" }))
+ XCTAssertEqual(saved.atMs, 103_000)
+ XCTAssertEqual(saved.markerType, "moment")
+ XCTAssertEqual(saved.text, "Recovered")
+
+ let events = try XCTUnwrap(store.exportEntries(for: updated)
+ .first(where: { $0.name == "events.jsonl" }))
+ XCTAssertTrue(String(decoding: events.data, as: UTF8.self).contains("\"marker_type\":\"moment\""))
+ let meta = try XCTUnwrap(store.exportEntries(for: updated)
+ .first(where: { $0.name == "meta.json" }))
+ let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any])
+ let markers = try XCTUnwrap(object["markers"] as? [[String: Any]])
+ XCTAssertEqual(markers.first?["type"] as? String, "moment")
+
+ store.deleteMarker(sessionId: session.id, markerId: try XCTUnwrap(saved.markerId))
+ XCTAssertFalse(try XCTUnwrap(store.sessions.first).events.contains(where: { $0.kind == "marker" }))
+ }
+
+ func testMarkerTimeIsClampedToSession() throws {
+ let store = RawDataSessionStore(directory: try temporaryDirectory())
+ let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)))
+ store.stop(now: Date(timeIntervalSince1970: 110))
+ let marker = try XCTUnwrap(store.addMarker(sessionId: session.id,
+ at: Date(timeIntervalSince1970: 200), type: "end", text: ""))
+ XCTAssertEqual(marker.atMs, 110_000)
+ }
+
+ func testEditedRangeFiltersExportWithoutDestroyingMarkers() throws {
+ let store = RawDataSessionStore(directory: try temporaryDirectory())
+ let session = try XCTUnwrap(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)))
+ _ = store.addMarker(sessionId: session.id, at: Date(timeIntervalSince1970: 102),
+ type: "start", text: "outside")
+ _ = store.addMarker(sessionId: session.id, at: Date(timeIntervalSince1970: 105),
+ type: "moment", text: "inside")
+ store.stop(now: Date(timeIntervalSince1970: 110))
+ store.setRange(sessionId: session.id, from: Date(timeIntervalSince1970: 104),
+ to: Date(timeIntervalSince1970: 108))
+
+ let edited = try XCTUnwrap(store.sessions.first)
+ XCTAssertEqual(edited.events.filter { $0.kind == "marker" }.count, 2)
+ let events = try XCTUnwrap(store.exportEntries(for: edited)
+ .first(where: { $0.name == "events.jsonl" }))
+ let text = String(decoding: events.data, as: UTF8.self)
+ XCTAssertFalse(text.contains("outside"))
+ XCTAssertTrue(text.contains("inside"))
+ }
+
+ func testExportReportsSensorAvailabilityFromAssembledPayload() throws {
+ let store = RawDataSessionStore(directory: try temporaryDirectory())
+ _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))
+ store.stop(now: Date(timeIntervalSince1970: 110))
+ let session = try XCTUnwrap(store.sessions.first)
+ let meta = try XCTUnwrap(store.exportEntries(for: session, sensorAvailable: true)
+ .first(where: { $0.name == "meta.json" }))
+ let object = try XCTUnwrap(JSONSerialization.jsonObject(with: meta.data) as? [String: Any])
+ XCTAssertEqual(object["sensor_export_available"] as? Bool, true)
+ XCTAssertFalse(store.exportEntries(for: session).contains { $0.name == "raw-frames.jsonl" })
+ }
+
+ func testFullSecondBoundsExcludePartialEndpointSeconds() {
+ let bounds = RawDataCollectorView.fullSecondBounds(fromMs: 100_001, toMs: 104_999)
+ XCTAssertEqual(bounds?.from, 101)
+ XCTAssertEqual(bounds?.to, 103)
+ XCTAssertNil(RawDataCollectorView.fullSecondBounds(fromMs: 100_001, toMs: 100_999))
+ }
+
+ func testMarkerCurrentTimeTicksOnlyForActiveSession() throws {
+ let store = RawDataSessionStore(directory: try temporaryDirectory())
+ _ = store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100))
+ let active = try XCTUnwrap(store.sessions.first)
+ XCTAssertEqual(RawDataCollectorView.markerCurrentTime(session: active,
+ now: Date(timeIntervalSince1970: 105)).timeIntervalSince1970, 105)
+ store.stop(now: Date(timeIntervalSince1970: 110))
+ let stopped = try XCTUnwrap(store.sessions.first)
+ XCTAssertEqual(RawDataCollectorView.markerCurrentTime(session: stopped,
+ now: Date(timeIntervalSince1970: 999)).timeIntervalSince1970, 110)
+ }
+
+ func testActiveSessionCannotBeDeleted() throws {
+ let store = RawDataSessionStore(directory: try temporaryDirectory())
+ let active = try XCTUnwrap(store.start(deviceId: "strap"))
+ store.removeMetadata(active.id)
+ XCTAssertEqual(store.sessions.map(\.id), [active.id])
+ }
+
+ func testLastExportTimePersists() throws {
+ let directory = try temporaryDirectory()
+ let store = RawDataSessionStore(directory: directory)
+ let session = try XCTUnwrap(store.start(deviceId: "strap"))
+ store.stop()
+ store.markExported(session.id, now: Date(timeIntervalSince1970: 123))
+
+ let restored = try XCTUnwrap(RawDataSessionStore(directory: directory).sessions.first)
+ XCTAssertTrue(restored.exported)
+ XCTAssertEqual(restored.lastExportedAtMs, 123_000)
+ }
+
+ func testCompletedSessionCanBeDeleted() throws {
+ let directory = try temporaryDirectory()
+ let store = RawDataSessionStore(directory: directory)
+ let session = try XCTUnwrap(store.start(deviceId: "strap"))
+ store.stop()
+ XCTAssertTrue(store.removeMetadata(session.id))
+ XCTAssertTrue(store.sessions.isEmpty)
+ XCTAssertTrue(RawDataSessionStore(directory: directory).sessions.isEmpty)
+ }
+
+ func testFailedSourceDeletionKeepsSessionRetryable() throws {
+ let directory = try temporaryDirectory()
+ let store = RawDataSessionStore(directory: directory)
+ let session = try XCTUnwrap(store.start(deviceId: "strap"))
+ store.stop()
+
+ XCTAssertFalse(store.removeMetadata(session.id) { _ in
+ throw CocoaError(.fileWriteNoPermission)
+ })
+ XCTAssertEqual(store.sessions.map(\.id), [session.id])
+ XCTAssertEqual(RawDataSessionStore(directory: directory).sessions.map(\.id), [session.id])
+ XCTAssertTrue(store.removeMetadata(session.id))
+ }
+}
diff --git a/StrandTests/RawPhysiologyUnionTests.swift b/StrandTests/RawPhysiologyUnionTests.swift
new file mode 100644
index 0000000000..7188981392
--- /dev/null
+++ b/StrandTests/RawPhysiologyUnionTests.swift
@@ -0,0 +1,123 @@
+import XCTest
+import WhoopProtocol
+import WhoopStore
+import StrandAnalytics
+@testable import Strand
+
+final class RawPhysiologyUnionTests: XCTestCase {
+ func testRRMergePreservesDistinctSameTimestampBeatsAndDedupesExactIdentityActiveFirst() {
+ let active = [RRInterval(ts: 100, rrMs: 800, ord: 8, seq: 0),
+ RRInterval(ts: 100, rrMs: 800, ord: 9, seq: 1)]
+ let canonical = [RRInterval(ts: 100, rrMs: 800, ord: 1, seq: 0),
+ RRInterval(ts: 101, rrMs: 810, ord: 2, seq: 0)]
+ let merged = Repository.mergeRRByIdentity([active, canonical])
+ XCTAssertEqual(merged.map { "\($0.ts):\($0.rrMs):\($0.seq)" },
+ ["100:800:0", "100:800:1", "101:810:0"])
+ XCTAssertEqual(merged.first?.ord, 8, "active source wins only the exact duplicate")
+ }
+
+ func testSingleSourceRRIsReturnedUnchanged() {
+ let rows = [RRInterval(ts: 101, rrMs: 810, ord: 4, seq: 2),
+ RRInterval(ts: 100, rrMs: 800, ord: 3, seq: 1)]
+ XCTAssertEqual(Repository.mergeRRByIdentity([rows]), rows)
+ }
+
+ func testRRUnionSortsByTimestampThenSequenceBeforeValueAndOrder() {
+ let rows = Repository.mergeRRByIdentity([
+ [RRInterval(ts: 100, rrMs: 700, ord: 0, seq: 1)],
+ [RRInterval(ts: 100, rrMs: 900, ord: 9, seq: 0),
+ RRInterval(ts: 100, rrMs: 800, ord: nil, seq: 0)],
+ ])
+ XCTAssertEqual(rows.map { "\($0.seq):\($0.rrMs):\($0.ord.map(String.init) ?? "nil")" },
+ ["0:800:nil", "0:900:9", "1:700:0"])
+ }
+
+ @MainActor
+ func testRRFacadeUnionsReAddedStrapAndCanonical() async throws {
+ let store = try await WhoopStore.inMemory()
+ let repo = Repository(deviceId: "my-whoop")
+ repo.setStoreForTesting(store)
+ _ = try await store.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 800)]), deviceId: "my-whoop")
+ _ = try await store.insert(Streams(rr: [RRInterval(ts: 101, rrMs: 810)]), deviceId: "whoop-new")
+ repo.adoptActiveDeviceId("whoop-new")
+ let rows = await repo.rrIntervals(from: 90, to: 110)
+ XCTAssertEqual(rows.map(\.ts), [100, 101])
+ }
+
+ @MainActor
+ func testRawFacadeIncludesAnotherRegisteredWhoopNotCurrentlyActive() async throws {
+ let store = try await WhoopStore.inMemory()
+ let registry = DeviceRegistryStore(dbQueue: store.registryWriter)
+ try registry.add(PairedDevice(id: "whoop-a", brand: "WHOOP", model: "5.0",
+ sourceKind: .liveBLE, capabilities: [.hr], status: .archived, addedAt: 1, lastSeenAt: 1))
+ try registry.add(PairedDevice(id: "whoop-b", brand: "WHOOP", model: "5.0",
+ sourceKind: .liveBLE, capabilities: [.hr], status: .active, addedAt: 2, lastSeenAt: 2))
+ _ = try await store.insert(Streams(hr: [HRSample(ts: 100, bpm: 61)]), deviceId: "whoop-a")
+ _ = try await store.insert(Streams(hr: [HRSample(ts: 200, bpm: 72)]), deviceId: "whoop-b")
+ let repo = Repository(deviceId: "whoop-b")
+ repo.setStoreForTesting(store)
+ let rows = await repo.hrSamples(from: 90, to: 210)
+ XCTAssertEqual(rows.map(\.bpm), [61, 72])
+
+ let timeline = await repo.timelineSeries(metric: .hr, from: 90, to: 210, targetPoints: 600)
+ XCTAssertEqual(timeline.points.map { Int($0.value) }, [61, 72],
+ "default Deep Timeline must use the same all-WHOOP raw resolver")
+ }
+
+ @MainActor
+ func testSessionMotionUsesHistoricalOwnerBeforeCurrentActiveComputedSource() async throws {
+ let store = try await WhoopStore.inMemory()
+ let session = CachedSleepSession(startTs: 1_000, endTs: 5_000, efficiency: 0.9,
+ restingHr: 52, avgHrv: 60, stagesJSON: nil)
+ _ = try await store.upsertSleepSessions([session], deviceId: "my-whoop")
+ _ = try await store.upsertSleepSessions([session], deviceId: "my-whoop-noop")
+ _ = try await store.persistSessionMotion(deviceId: "my-whoop-noop", sessionStart: 1_000,
+ motionEpochs: [0.1, 0.2])
+ _ = try await store.upsertSleepSessions([session], deviceId: "whoop-new-noop")
+ _ = try await store.persistSessionMotion(deviceId: "whoop-new-noop", sessionStart: 1_000,
+ motionEpochs: [9.0])
+ let repo = Repository(deviceId: "whoop-new")
+ repo.setStoreForTesting(store)
+ let motion = await repo.sessionMotions(sessions: [session])
+ XCTAssertEqual(motion[1_000] ?? [], [0.1, 0.2])
+ }
+
+ @MainActor
+ func testArchivedStrapNightsTeachHabitualMidsleep() async throws {
+ let store = try await WhoopStore.inMemory()
+ let registry = DeviceRegistryStore(dbQueue: store.registryWriter)
+ try registry.add(PairedDevice(id: "whoop-old", brand: "WHOOP", model: "4.0",
+ sourceKind: .liveBLE, capabilities: [.sleep], status: .archived, addedAt: 1, lastSeenAt: 1))
+ try registry.add(PairedDevice(id: "whoop-new", brand: "WHOOP", model: "5.0",
+ sourceKind: .liveBLE, capabilities: [.sleep], status: .active, addedAt: 2, lastSeenAt: 2))
+ let firstStart = Int(Date().timeIntervalSince1970) - 20 * 86_400
+ let nights = (0.. BodyVitalReading {
+ BodyVitalReading(
+ key: "skin", label: "Skin Temp", unit: "°C", value: 34.6,
+ format: { String(format: "%.1f", $0) },
+ banding: VitalBands.Result(band: .inRange, basis: .population, nights: 24),
+ metricColor: .orange, day: "2026-08-25", source: .noopComputed,
+ missingCaption: "none", caveat: caveat, secondary: secondary)
+ }
+
+ func testTheSecondaryLeadsTheCaptionSoItSitsUnderTheValue() {
+ let caption = reading(secondary: "+0.9 Δ°F").stateCaption
+ XCTAssertTrue(caption.hasPrefix("+0.9 Δ°F · "),
+ "the deviation must come first, directly under the headline — got \(caption)")
+ XCTAssertTrue(caption.contains("25 Aug") || caption.contains("Aug 25"),
+ "the day must still be there — got \(caption)")
+ }
+
+ func testWithoutASecondaryTheCaptionIsUnchanged() {
+ // Every other vital passes nil, so their captions must be byte-identical to before.
+ let caption = reading(secondary: nil).stateCaption
+ XCTAssertFalse(caption.hasPrefix(" · "), "a nil secondary must not leave an empty leading part")
+ XCTAssertFalse(caption.contains("Δ"))
+ }
+
+ func testTheSecondaryIsIndependentOfTheCaveat() {
+ // `caveat` says the reading is unreliable; `secondary` says what it means. A tile may carry
+ // both, and they must not be confused for one another.
+ let caption = reading(secondary: "+0.9 Δ°F", caveat: "unverified").stateCaption
+ XCTAssertTrue(caption.hasPrefix("+0.9 Δ°F · "))
+ XCTAssertTrue(caption.hasSuffix("unverified"))
+ }
+
+ // MARK: - The tile itself, not just the caption helper
+
+ /// A day key `daysAgo` before the CURRENT logical day.
+ ///
+ /// Not a hardcoded date: `latest()` resolves through `Baselines.freshestCarried`, which only carries
+ /// a reading within `vitalCarryDays` of today. A fixed fixture date passes now and silently stops
+ /// resolving once the real clock moves past that window — a test that rots into a false green.
+ private func dayKey(_ daysAgo: Int) -> String {
+ Baselines.cutoffKey(todayKey: BodyVitalSigns.logicalDayKey(Date()), carryDays: daysAgo)
+ }
+
+ private func day(_ d: String, abs: Double? = nil, dev: Double? = nil) -> DailyMetric {
+ DailyMetric(day: d, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil,
+ lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil,
+ recovery: nil, strain: nil, exerciseCount: nil,
+ skinTempDevC: dev, skinTempC: abs)
+ }
+
+ private func skinTile(_ days: [DailyMetric],
+ _ unit: TemperatureUnit = .celsius) throws -> BodyVitalReading {
+ let all = BodyVitalSigns.readings(days: days, today: days.last, temperatureUnit: unit)
+ return try XCTUnwrap(all.first { $0.key == "skin" })
+ }
+
+ /// The reported case: the night measured both, so the absolute leads and the deviation sits under it.
+ func testANightWithBothLeadsWithTheAbsolute() throws {
+ let tile = try skinTile([day(dayKey(0), abs: 34.6, dev: 0.52)])
+ XCTAssertEqual(try XCTUnwrap(tile.value), 34.6, accuracy: 0.001)
+ XCTAssertEqual(tile.unit, "°C")
+ XCTAssertEqual(tile.secondary, "+0.5 Δ°C")
+ }
+
+ func testTheAbsoluteAndItsNoteFollowTheTemperatureSetting() throws {
+ let tile = try skinTile([day(dayKey(0), abs: 34.6, dev: 0.52)], .fahrenheit)
+ XCTAssertEqual(tile.unit, "°F")
+ XCTAssertEqual(tile.secondary, "+0.9 Δ°F")
+ }
+
+ /// A night predating the column keeps exactly the tile that shipped before.
+ func testADeviationOnlyNightIsUnchanged() throws {
+ let tile = try skinTile([day(dayKey(0), dev: 0.52)])
+ XCTAssertEqual(try XCTUnwrap(tile.value), 0.52, accuracy: 0.001)
+ XCTAssertEqual(tile.unit, "Δ°C")
+ XCTAssertNil(tile.secondary, "a deviation-led tile would only repeat its own headline")
+ }
+
+ /// Calibrating: the absolute is measured before the baseline is usable, so there is no deviation
+ /// yet. The tile shows the temperature instead of "needs ~4 worn nights", and omits the note.
+ func testACalibratingNightShowsTheAbsoluteWithNoNote() throws {
+ let tile = try skinTile([day(dayKey(0), abs: 34.6)])
+ XCTAssertEqual(try XCTUnwrap(tile.value), 34.6, accuracy: 0.001)
+ XCTAssertEqual(tile.unit, "°C")
+ XCTAssertNil(tile.secondary)
+ }
+
+ /// The regression the displayed-row rule exists for: an import-only night is NEWER than the last
+ /// strap night. The tile must stay on the import rather than stepping back to a stale absolute.
+ func testANewerImportOnlyNightDoesNotStepBackToAnOlderAbsolute() throws {
+ let tile = try skinTile([
+ day(dayKey(1), abs: 34.6, dev: 0.52), // the strap's last scored night
+ day(dayKey(0), dev: 0.20), // a WHOOP CSV import: deviation only
+ ])
+ XCTAssertEqual(try XCTUnwrap(tile.value), 0.20, accuracy: 0.001,
+ "showing 34.6 here would be yesterday's reading dressed as today's")
+ XCTAssertEqual(tile.day, dayKey(0))
+ XCTAssertNil(tile.secondary)
+ }
+
+ /// The secondary must be THIS night's deviation. Reaching for the freshest one anywhere would
+ /// print a previous night's number under tonight's temperature.
+ func testTheNoteBelongsToTheDisplayedNight() throws {
+ let tile = try skinTile([
+ day(dayKey(1), abs: 33.9, dev: -0.40),
+ day(dayKey(0), abs: 34.6, dev: 0.52),
+ ])
+ XCTAssertEqual(tile.secondary, "+0.5 Δ°C", "the note must not come from the previous night")
+ }
+
+ /// The formatting the tile's secondary carries, pinned to the same helper Android formats with.
+ func testTheDeviationNoteMatchesTheKotlinTwin() {
+ func note(_ c: Double, fahrenheit: Bool) -> String {
+ let n = SkinTempDisplay.numberString(c, kind: .deviation, fahrenheit: fahrenheit, decimals: 1)
+ return "\(n) \(SkinTempDisplay.unitSymbol(kind: .deviation, fahrenheit: fahrenheit))"
+ }
+ XCTAssertEqual(note(0.52, fahrenheit: false), "+0.5 Δ°C")
+ XCTAssertEqual(note(0.52, fahrenheit: true), "+0.9 Δ°F")
+ XCTAssertEqual(note(-0.5, fahrenheit: false), "-0.5 Δ°C")
+ XCTAssertEqual(note(-0.5, fahrenheit: true), "-0.9 Δ°F")
+ // A whole degree of DEVIATION is 1.8 °F, never 33.8 — no +32 offset on a difference.
+ XCTAssertEqual(note(1.0, fahrenheit: true), "+1.8 Δ°F")
+ }
+}
diff --git a/StrandTests/SkinTempBackfillTests.swift b/StrandTests/SkinTempBackfillTests.swift
new file mode 100644
index 0000000000..09ed34925c
--- /dev/null
+++ b/StrandTests/SkinTempBackfillTests.swift
@@ -0,0 +1,229 @@
+import XCTest
+import StrandAnalytics
+import WhoopProtocol
+
+/// #1853: tests for the PURE half of the skin-temp absolute backfill — the session-attribution rule
+/// (rule 1), the anchor rule (rule 3), and the pagination rule (rule 2). The I/O walker that reads
+/// from / writes to `WhoopStore` is covered separately.
+final class SkinTempBackfillTests: XCTestCase {
+
+ // MARK: Rule 1 — attribute sessions by END
+
+ /// A session whose END falls in the day is matched; one whose END falls in the previous day is not.
+ /// The night window is 54 h wide and contains the previous night, so filtering by end is the only
+ /// thing that keeps two nights from being averaged into one temperature.
+ func testSessionsForDay_matchesByEndNotStart() {
+ // dayStart = 2026-08-25 00:00 UTC = 1_789_632_000
+ let dayStart = 1_789_632_000
+ let prevNightEnd = dayStart - 3_600 // 23:00 the PREVIOUS day → NOT matched
+ let thisNightEnd = dayStart + 7 * 3_600 // 07:00 this day → matched
+ let nextNightEnd = dayStart + 86_400 + 3_600 // 01:00 NEXT day → NOT matched
+ let sessions = [
+ SleepSession(start: dayStart - 8 * 3_600, end: prevNightEnd, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ SleepSession(start: dayStart - 1 * 3_600, end: thisNightEnd, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ SleepSession(start: dayStart + 20 * 3_600, end: nextNightEnd, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ ]
+ let matched = SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart)
+ XCTAssertEqual(matched.count, 1)
+ XCTAssertEqual(matched.first?.end, thisNightEnd)
+ }
+
+ /// A session ending exactly at midnight (dayStart) is matched (>= dayStart).
+ func testSessionsForDay_inclusiveAtDayStart() {
+ let dayStart = 1_789_632_000
+ let sessions = [
+ SleepSession(start: dayStart - 8 * 3_600, end: dayStart, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ ]
+ XCTAssertEqual(SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart).count, 1)
+ }
+
+ /// A session ending exactly at next midnight (dayStart + 86400) is NOT matched (< dayEnd).
+ func testSessionsForDay_exclusiveAtDayEnd() {
+ let dayStart = 1_789_632_000
+ let sessions = [
+ SleepSession(start: dayStart + 12 * 3_600, end: dayStart + 86_400, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ ]
+ XCTAssertEqual(SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart).count, 0)
+ }
+
+ /// Empty sessions → empty match (the night declines with "no sleep session ends on this day").
+ func testSessionsForDay_emptySessionsReturnsEmpty() {
+ XCTAssertEqual(SkinTempBackfill.sessionsForDay([], dayStart: 0).count, 0)
+ }
+
+ // MARK: Rule 3 — the WHOOP 4.0 anchor comes from the current window
+
+ /// A WHOOP 4.0 with a per-device anchor from the current window uses it.
+ func testResolveAnchor_whoop4WithAnchorReturnsIt() {
+ let anchor = SkinTempBackfill.resolveAnchor(family: .whoop4, windowAnchorRaw: 1290.0)
+ XCTAssertEqual(anchor, 1290.0)
+ }
+
+ /// A WHOOP 4.0 with NO per-device anchor returns nil → the night DECLINES. The global 826 is NOT
+ /// a fallback here — a re-learned anchor puts backfilled nights on a different offset from the
+ /// stored ones and the chart plots two scales as one line.
+ func testResolveAnchor_whoop4WithoutAnchorDeclines() {
+ let anchor = SkinTempBackfill.resolveAnchor(family: .whoop4, windowAnchorRaw: nil)
+ XCTAssertNil(anchor, "rule 3: no anchor ⇒ decline, never the global default")
+ }
+
+ /// A WHOOP 5/MG always returns nil anchor (centidegree path, no anchor) — and does NOT decline on
+ /// anchor grounds (the decline check in `computeNight` is `family == .whoop4 && anchor == nil`).
+ func testResolveAnchor_whoop5ReturnsNilButDoesNotDecline() {
+ let anchor = SkinTempBackfill.resolveAnchor(family: .whoop5, windowAnchorRaw: nil)
+ XCTAssertNil(anchor)
+ // The computeNight test below confirms a 5/MG night proceeds without an anchor.
+ }
+
+ // MARK: computeNight — the full per-night plan
+
+ /// A 5/MG night with enough worn samples fills. The centidegree path needs no anchor.
+ func testComputeNight_whoop5WithEnoughSamplesFills() {
+ let dayStart = 1_789_632_000
+ let candidate = SkinTempBackfill.NightCandidate(
+ day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop")
+ // A session ending in this day.
+ let sessions = [
+ SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ ]
+ // 400 worn skin-temp samples (3057 raw = 30.57 °C on 5/MG) at 1 Hz, with matching HR.
+ let skinTemp = (0..<400).map { i in
+ SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 3057)
+ }
+ let hr = (0..<400).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) }
+ let result = SkinTempBackfill.computeNight(
+ candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp,
+ family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart)
+ XCTAssertNotNil(result.skinTempC)
+ XCTAssertEqual(result.skinTempC!, 30.57, accuracy: 0.01)
+ XCTAssertNil(result.declineReason)
+ }
+
+ /// A WHOOP 4.0 night with no per-device anchor DECLINES (rule 3). Even with enough samples the
+ /// night is not filled — the global 826 is NOT a fallback.
+ func testComputeNight_whoop4WithoutAnchorDeclines() {
+ let dayStart = 1_789_632_000
+ let candidate = SkinTempBackfill.NightCandidate(
+ day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop")
+ let sessions = [
+ SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ ]
+ // Enough samples to fill IF the anchor were allowed — but rule 3 declines before the funnel.
+ let skinTemp = (0..<400).map { i in
+ SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 826)
+ }
+ let hr = (0..<400).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) }
+ let result = SkinTempBackfill.computeNight(
+ candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp,
+ family: .whoop4, windowAnchorRaw: nil, dayStart: dayStart)
+ XCTAssertNil(result.skinTempC, "rule 3: no anchor ⇒ decline, never the global default")
+ XCTAssertNotNil(result.declineReason)
+ XCTAssertTrue(result.declineReason!.contains("anchor"), "decline reason names the anchor")
+ }
+
+ /// A night with no sessions ending on the day declines.
+ func testComputeNight_noSessionsDeclines() {
+ let dayStart = 1_789_632_000
+ let candidate = SkinTempBackfill.NightCandidate(
+ day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop")
+ let result = SkinTempBackfill.computeNight(
+ candidate: candidate, sessions: [], hr: [], skinTemp: [],
+ family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart)
+ XCTAssertNil(result.skinTempC)
+ XCTAssertEqual(result.declineReason, "no sleep session ends on this day")
+ }
+
+ /// A night with too few worn samples (< minSkinTempSamples = 300) declines.
+ func testComputeNight_tooFewSamplesDeclines() {
+ let dayStart = 1_789_632_000
+ let candidate = SkinTempBackfill.NightCandidate(
+ day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop")
+ let sessions = [
+ SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ ]
+ // Only 100 samples — below the 300 floor.
+ let skinTemp = (0..<100).map { i in
+ SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 3057)
+ }
+ let hr = (0..<100).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) }
+ let result = SkinTempBackfill.computeNight(
+ candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp,
+ family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart)
+ XCTAssertNil(result.skinTempC)
+ XCTAssertNotNil(result.declineReason)
+ XCTAssertTrue(result.declineReason!.contains("worn samples"), "decline reason names the sample floor")
+ }
+
+ /// A night whose sessions END in the PREVIOUS day is not matched (rule 1), even if the session
+ /// span overlaps this day's window. This is the exact bug rule 1 prevents: two nights averaged
+ /// into one temperature.
+ func testComputeNight_previousNightSessionNotMatched() {
+ let dayStart = 1_789_632_000
+ let candidate = SkinTempBackfill.NightCandidate(
+ day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop")
+ // A session that ends in the PREVIOUS day (23:00 on Aug 24) but whose span overlaps this day's
+ // 54 h window. Without rule 1 this would be matched and the night would fill.
+ let sessions = [
+ SleepSession(start: dayStart - 9 * 3_600, end: dayStart - 1 * 3_600, efficiency: 0.9,
+ stages: [], restingHR: nil, avgHRV: nil, hrOnly: false),
+ ]
+ let skinTemp = (0..<400).map { i in
+ SkinTempSample(ts: dayStart - 9 * 3_600 + i, raw: 3057)
+ }
+ let hr = (0..<400).map { i in HRSample(ts: dayStart - 9 * 3_600 + i, bpm: 60) }
+ let result = SkinTempBackfill.computeNight(
+ candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp,
+ family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart)
+ XCTAssertNil(result.skinTempC, "rule 1: a session ending in the previous day is not this day's")
+ XCTAssertEqual(result.declineReason, "no sleep session ends on this day")
+ }
+
+ // MARK: Rule 2 — page the candidates
+
+ /// Page 0 returns the first `pageSize` candidates, oldest-first.
+ func testPage_firstPageReturnsOldestN() {
+ let candidates = (0..<120).map { i in
+ SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))",
+ from: 0, to: 0, owner: "my-whoop")
+ }
+ let page0 = SkinTempBackfill.page(candidates, page: 0, pageSize: 50)
+ XCTAssertEqual(page0.count, 50)
+ XCTAssertEqual(page0.first?.day, "2026-08-01")
+ XCTAssertEqual(page0.last?.day, "2026-08-50")
+ }
+
+ /// Page 2 returns the next chunk; page 3 is empty (only 120 candidates, 50 per page → 3 pages).
+ func testPage_thirdPageEmpty() {
+ let candidates = (0..<120).map { i in
+ SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))",
+ from: 0, to: 0, owner: "my-whoop")
+ }
+ let page2 = SkinTempBackfill.page(candidates, page: 2, pageSize: 50)
+ XCTAssertEqual(page2.count, 20)
+ let page3 = SkinTempBackfill.page(candidates, page: 3, pageSize: 50)
+ XCTAssertTrue(page3.isEmpty, "page 3 is empty — the walker stops here")
+ }
+
+ /// An empty candidate list returns an empty page on every page.
+ func testPage_emptyCandidatesReturnsEmpty() {
+ XCTAssertTrue(SkinTempBackfill.page([], page: 0).isEmpty)
+ }
+
+ /// A partial last page returns exactly the remainder.
+ func testPage_partialLastPage() {
+ let candidates = (0..<75).map { i in
+ SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))",
+ from: 0, to: 0, owner: "my-whoop")
+ }
+ let page1 = SkinTempBackfill.page(candidates, page: 1, pageSize: 50)
+ XCTAssertEqual(page1.count, 25)
+ }
+}
diff --git a/StrandTests/SkinTempExplorerNoteTests.swift b/StrandTests/SkinTempExplorerNoteTests.swift
new file mode 100644
index 0000000000..25778f625c
--- /dev/null
+++ b/StrandTests/SkinTempExplorerNoteTests.swift
@@ -0,0 +1,66 @@
+import XCTest
+import StrandAnalytics
+@testable import Strand
+
+/// #1848: the skin-temp explorer's two explanatory notes (#1847), pure-function twins of Android's
+/// `shouldExplainSkinTempFallback` and `shouldExplainShortenedSkinTempSeries`.
+///
+/// Both notes are gated so they fire ONLY when the sentence they carry is true — a note that says the
+/// opposite of what happened is worse than silence. The fallback note fires when the user asked for
+/// temperatures and the window has none; the shortened-series note fires when leading with absolutes
+/// dropped deviation-only nights. The deviation-led branch also drops rows (calibrating nights with
+/// only an absolute), but those are the OPPOSITE kind, so the shortened-series note is gated to
+/// absolute-led only.
+final class SkinTempExplorerNoteTests: XCTestCase {
+
+ // MARK: - shouldExplainSkinTempFallback
+
+ func testFallbackExplainedWhenUserAskedForAbsoluteButWindowHasNone() {
+ XCTAssertTrue(shouldExplainSkinTempFallback(
+ prefer: .absolute, leadsAbsolute: false, anyAbsoluteInWindow: false))
+ }
+
+ func testFallbackSilentWhenWindowHasAbsoluteEvenIfLatestLacksOne() {
+ // #1850: the preference applies across the window. A window with one stored temperature
+ // and twenty deltas still leads with temperatures, so the fallback note stays silent.
+ XCTAssertFalse(shouldExplainSkinTempFallback(
+ prefer: .absolute, leadsAbsolute: true, anyAbsoluteInWindow: true))
+ }
+
+ func testFallbackSilentWhenUserAskedForDeviation() {
+ // The user chose "vs baseline" — showing deviations is honouring the choice, not falling back.
+ XCTAssertFalse(shouldExplainSkinTempFallback(
+ prefer: .deviation, leadsAbsolute: false, anyAbsoluteInWindow: false))
+ }
+
+ func testFallbackSilentWhenLeadingAbsoluteAsRequested() {
+ XCTAssertFalse(shouldExplainSkinTempFallback(
+ prefer: .absolute, leadsAbsolute: true, anyAbsoluteInWindow: true))
+ }
+
+ // MARK: - shouldExplainShortenedSkinTempSeries
+
+ func testShortenedSeriesExplainedWhenAbsoluteLedDropsNights() {
+ XCTAssertTrue(shouldExplainShortenedSkinTempSeries(
+ leadsAbsolute: true, shownReadings: 21, rowsWithEitherNumber: 40))
+ }
+
+ func testShortenedSeriesSilentWhenEveryNightIsShown() {
+ XCTAssertFalse(shouldExplainShortenedSkinTempSeries(
+ leadsAbsolute: true, shownReadings: 23, rowsWithEitherNumber: 23))
+ }
+
+ func testShortenedSeriesSilentWhenNothingToShow() {
+ XCTAssertFalse(shouldExplainShortenedSkinTempSeries(
+ leadsAbsolute: true, shownReadings: 0, rowsWithEitherNumber: 0))
+ }
+
+ func testShortenedSeriesNeverExplainsWhenLeadingWithDeviation() {
+ // The deviation-led branch ALSO drops rows (calibrating nights with only an absolute), but
+ // those are the OPPOSITE kind — "only nights with a measured temperature are shown" would be
+ // precisely backwards. Ungated, this fired the moment a wearer picked "vs baseline" with any
+ // calibrating night in the window.
+ XCTAssertFalse(shouldExplainShortenedSkinTempSeries(
+ leadsAbsolute: false, shownReadings: 21, rowsWithEitherNumber: 40))
+ }
+}
diff --git a/StrandTests/SleepCarriedStampTests.swift b/StrandTests/SleepCarriedStampTests.swift
new file mode 100644
index 0000000000..1208dbfe52
--- /dev/null
+++ b/StrandTests/SleepCarriedStampTests.swift
@@ -0,0 +1,73 @@
+import XCTest
+import WhoopStore
+@testable import Strand
+
+/// #1946: a carried prior-night metric value must be stamped with its day so it is not passed off
+/// as tonight's read. The `SleepModel.Metric` tuple now carries `latestDay` alongside `latest`, and
+/// `carriedMetricCaption` returns "Carried · " when the value is from a prior day. Byte-parity
+/// twin of Kotlin `SleepCarriedStampTest`.
+final class SleepCarriedStampTests: XCTestCase {
+
+ private func day(_ d: String, resp: Double? = nil, eff: Double? = nil) -> DailyMetric {
+ DailyMetric(day: d, totalSleepMin: 420, efficiency: eff,
+ deepMin: 80, remMin: 90, lightMin: 200, disturbances: nil,
+ restingHr: nil, avgHrv: nil, recovery: nil, strain: nil,
+ exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: resp)
+ }
+
+ private func noon(_ d: String) -> Date {
+ let f = DateFormatter()
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.dateFormat = "yyyy-MM-dd HH:mm"
+ f.timeZone = .current
+ return f.date(from: "\(d) 12:00")!
+ }
+
+ /// A value from TODAY's own day is NOT carried — `latestDay` is nil, no stamp.
+ func testTodayValueIsNotCarried() {
+ let days = [day("2026-08-13", resp: 15.6)]
+ let resp = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.respRateBpm }
+ XCTAssertEqual(resp.latest ?? 0, 15.6, accuracy: 1e-9)
+ XCTAssertNil(resp.latestDay, "today's own value must not be marked as carried")
+ XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: resp.latestDay, latest: resp.latest))
+ }
+
+ /// A value from a PRIOR day within the carry window IS carried — `latestDay` is set, and the
+ /// caption stamps it.
+ func testPriorDayValueIsCarriedAndStamped() {
+ let days = [day("2026-08-11", resp: 14.1), day("2026-08-12")]
+ let resp = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.respRateBpm }
+ XCTAssertEqual(resp.latest ?? 0, 14.1, accuracy: 1e-9)
+ XCTAssertEqual(resp.latestDay, "2026-08-11", "the carried value's source day is tracked")
+ let caption = SleepModel.carriedMetricCaption(latestDay: resp.latestDay, latest: resp.latest)
+ XCTAssertNotNil(caption, "a carried value must produce a stamp caption")
+ XCTAssertTrue(caption!.contains("Carried"), caption!)
+ XCTAssertTrue(caption!.contains("11"), caption!)
+ }
+
+ /// A nil latest has no stamp — the tile falls through to "vs typical" or "—".
+ func testNoValueHasNoStamp() {
+ XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: "2026-08-11", latest: nil))
+ XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: nil, latest: 15.6))
+ XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: nil, latest: nil))
+ }
+
+ /// A stale value (outside the carry window) has no latest and no stamp — it was already nilled
+ /// by `freshestCarried`.
+ func testStaleValueHasNoLatestAndNoStamp() {
+ var days = [day("2026-07-29", resp: 16.2), day("2026-07-30", resp: 15.6)]
+ days += (1...13).map { day(String(format: "2026-08-%02d", $0)) }
+ let resp = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.respRateBpm }
+ XCTAssertNil(resp.latest)
+ XCTAssertNil(resp.latestDay)
+ XCTAssertNil(SleepModel.carriedMetricCaption(latestDay: resp.latestDay, latest: resp.latest))
+ }
+
+ /// A fresh sibling metric (efficiency, present every night) is NOT carried when today has a value.
+ func testFreshSiblingIsNotCarried() {
+ let days = [day("2026-08-12", eff: 90), day("2026-08-13", eff: 88)]
+ let eff = SleepModel.metric(days: days, now: noon("2026-08-13")) { $0.efficiency }
+ XCTAssertEqual(eff.latest ?? 0, 88, accuracy: 1e-9)
+ XCTAssertNil(eff.latestDay, "today's own efficiency is not carried")
+ }
+}
diff --git a/StrandTests/SleepLayoutPrefsTests.swift b/StrandTests/SleepLayoutPrefsTests.swift
index c492273544..475f574024 100644
--- a/StrandTests/SleepLayoutPrefsTests.swift
+++ b/StrandTests/SleepLayoutPrefsTests.swift
@@ -18,7 +18,9 @@ final class SleepLayoutPrefsTests: XCTestCase {
]
let encoded = SleepLayoutPrefs.encode(reordered)
XCTAssertEqual(encoded, "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical")
- XCTAssertEqual(SleepLayoutPrefs.decodeOrder(encoded), reordered)
+ // `bodyClock` is absent from the saved order, so decode inserts it at its default position — the
+ // migration path every newly-added section takes for an existing customised layout.
+ XCTAssertEqual(SleepLayoutPrefs.decodeOrder(encoded), [.bodyClock] + reordered)
}
/// A saved order that leads with `asleepDuration` and ends on `sleepMarks` keeps those two placements
@@ -28,7 +30,7 @@ final class SleepLayoutPrefsTests: XCTestCase {
let decoded = SleepLayoutPrefs.decodeOrder("asleepDuration,sleepMarks")
XCTAssertEqual(decoded.count, SleepSection.allCases.count)
XCTAssertEqual(decoded, [
- .stages, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, .sleepMarks,
+ .stages, .bodyClock, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, .sleepMarks,
])
}
@@ -56,7 +58,7 @@ final class SleepLayoutPrefsTests: XCTestCase {
let order = "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical"
XCTAssertEqual(
SleepLayoutPrefs.visibleOrder(orderRaw: order, hiddenRaw: "asleepDuration,sleepDebt"),
- [.nightDetail, .sleepMarks, .stages, .stagesVsTypical]
+ [.bodyClock, .nightDetail, .sleepMarks, .stages, .stagesVsTypical]
)
XCTAssertEqual(SleepLayoutPrefs.decodeOrder(order).count, SleepSection.allCases.count)
}
@@ -79,7 +81,8 @@ final class SleepLayoutPrefsTests: XCTestCase {
XCTAssertEqual(raws.count, Set(raws).count)
// Pin the exact wire strings — they cross the .noopbak boundary and must match Android byte-for-byte.
XCTAssertEqual(raws, [
- "sleepMarks", "stages", "nightDetail", "sleepDebt", "stagesVsTypical", "asleepDuration",
+ "sleepMarks", "stages", "bodyClock", "nightDetail", "sleepDebt", "stagesVsTypical",
+ "asleepDuration",
])
}
}
diff --git a/StrandTests/SleepModelDebtTests.swift b/StrandTests/SleepModelDebtTests.swift
new file mode 100644
index 0000000000..ba33402ae4
--- /dev/null
+++ b/StrandTests/SleepModelDebtTests.swift
@@ -0,0 +1,29 @@
+import XCTest
+import WhoopStore
+@testable import Strand
+
+final class SleepModelDebtTests: XCTestCase {
+ private func day(_ value: String, sleep: Double?) -> DailyMetric {
+ DailyMetric(day: value, totalSleepMin: sleep, efficiency: nil,
+ deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil,
+ restingHr: nil, avgHrv: nil, recovery: nil, strain: nil,
+ exerciseCount: nil, spo2Pct: nil, skinTempDevC: nil, respRateBpm: nil)
+ }
+
+ func testLocalFallbackLatestAgreesWithLedger() {
+ let days = [day("2026-06-01", sleep: 360), day("2026-06-02", sleep: 480)]
+ let metric = SleepModel.sleepDebtSeries(
+ days: days, importedSleep: [:], napSleepMinByDay: [:])
+ let ledger = SleepModel.debtLedger(days: days, napSleepMinByDay: [:])
+ XCTAssertEqual(metric.latest ?? -1, ledger.magnitudeMin, accuracy: 1e-9)
+ }
+
+ func testImportedWhoopDebtRemainsVerbatim() {
+ let days = [day("2026-06-01", sleep: 480)]
+ let imported = ["2026-06-01": ImportedSleepFigures(
+ performancePct: nil, consistencyPct: nil, needMin: nil, debtMin: 17.25)]
+ let metric = SleepModel.sleepDebtSeries(
+ days: days, importedSleep: imported, napSleepMinByDay: [:])
+ XCTAssertEqual(metric.latest ?? -1, 17.25, accuracy: 1e-9)
+ }
+}
diff --git a/StrandTests/SleepPhantomNightFallbackTests.swift b/StrandTests/SleepPhantomNightFallbackTests.swift
index 7d0388e1b3..fd0b0a45e5 100644
--- a/StrandTests/SleepPhantomNightFallbackTests.swift
+++ b/StrandTests/SleepPhantomNightFallbackTests.swift
@@ -77,4 +77,29 @@ final class SleepPhantomNightFallbackTests: XCTestCase {
XCTAssertNil(SleepEditGuard.clampedEditWindow(start: phantomWindow.0, end: phantomWindow.1,
now: Int(d(2, 5, 3).timeIntervalSince1970)))
}
+
+ // Missing-night honesty: the newest stored night may stay visible, but the status above it must say
+ // whether today's night is still moving through sync/analysis or finished without a detection.
+ func testFreshnessProgressStatesWinOverStaleHistory() {
+ XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: true,
+ calculating: true, syncedSinceDayStart: false, syncFailed: true), .syncing)
+ XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false,
+ calculating: true, syncedSinceDayStart: true, syncFailed: true), .calculating)
+ }
+
+ func testFreshnessDoesNotDeclareMissingBeforeMorningOrForCurrentNight() {
+ XCTAssertNil(resolveSleepFreshness(hasCurrentNight: false, morningReady: false, syncing: false,
+ calculating: false, syncedSinceDayStart: true, syncFailed: false))
+ XCTAssertNil(resolveSleepFreshness(hasCurrentNight: true, morningReady: true, syncing: false,
+ calculating: false, syncedSinceDayStart: true, syncFailed: false))
+ }
+
+ func testFreshnessDistinguishesFinishedFailedAndWaiting() {
+ XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false,
+ calculating: false, syncedSinceDayStart: true, syncFailed: false), .notDetected)
+ XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false,
+ calculating: false, syncedSinceDayStart: false, syncFailed: true), .syncFailed)
+ XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: true, syncing: false,
+ calculating: false, syncedSinceDayStart: false, syncFailed: false), .awaitingSync)
+ }
}
diff --git a/StrandTests/SmartAlarmWeekdayTests.swift b/StrandTests/SmartAlarmWeekdayTests.swift
index 815aae65b3..a77c489eb3 100644
--- a/StrandTests/SmartAlarmWeekdayTests.swift
+++ b/StrandTests/SmartAlarmWeekdayTests.swift
@@ -86,6 +86,70 @@ final class SmartAlarmWeekdayTests: XCTestCase {
XCTAssertGreaterThan(next!, wed(7, 0))
}
+ // MARK: nextSmartAlarmDate — per-weekday overrides (#1864)
+
+ /// An override on the scanned day moves the fire time to THAT day's override, not the default.
+ /// now = Wed 06:00, default 07:00, only Wednesdays selected (4), Wednesday override 03:30 →
+ /// today 03:30 has passed, so the next Wednesday (7 days later) at 03:30. Without the override
+ /// this would be today 07:00 (still ahead).
+ func testOverrideOnToday_afterTime_rollsToNextWeek() {
+ let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], overrides: [4: 3 * 60 + 30],
+ from: wed(6, 0), calendar: cal)
+ XCTAssertEqual(weekday(of: next!), 4, "Wednesday")
+ XCTAssertEqual(next, cal.date(byAdding: .day, value: 7, to: wed(3, 30)))
+ }
+
+ /// An override on the scanned day that is LATER than the default can make today's occurrence still
+ /// pending. now = Wed 06:00, default 07:00, Wednesday override 09:00 → today 09:00 (still ahead).
+ func testOverrideOnToday_beforeOverrideTime_firesTodayAtOverrideTime() {
+ let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], overrides: [4: 9 * 60],
+ from: wed(6, 0), calendar: cal)
+ XCTAssertEqual(next, wed(9, 0))
+ XCTAssertEqual(weekday(of: next!), 4)
+ }
+
+ /// A day WITHOUT an override falls back to the default time. now = Wed 06:00, default 07:00,
+ /// Tuesday override 03:30 → Wednesday has no override, fires today at 07:00.
+ func testDayWithoutOverrideUsesDefaultTime() {
+ let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], overrides: [3: 3 * 60 + 30],
+ from: wed(6, 0), calendar: cal)
+ XCTAssertEqual(next, wed(7, 0))
+ }
+
+ /// An override on a weekday the alarm doesn't fire on is ignored. now = Wed 06:00, default 07:00,
+ /// only Wednesdays selected (4), Saturday override 03:30 → today 07:00 (Saturday is skipped).
+ func testOverrideOnDisabledWeekdayIsIgnored() {
+ let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], overrides: [7: 3 * 60 + 30],
+ from: wed(6, 0), calendar: cal)
+ XCTAssertEqual(next, wed(7, 0))
+ }
+
+ /// An empty overrides map is byte-for-byte the old path — the default time on every enabled day.
+ func testEmptyOverridesIsByteForByteOldPath() {
+ let without = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], from: wed(6, 0), calendar: cal)
+ let withEmpty = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [4], overrides: [:],
+ from: wed(6, 0), calendar: cal)
+ XCTAssertEqual(without, withEmpty)
+ }
+
+ /// Invalid override entries (day outside 1…7, minute outside [0, 1440)) are dropped, not applied.
+ func testInvalidOverrideEntriesAreDropped() {
+ let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], overrides: [0: 3 * 60, 4: -1, 99: 9 * 60],
+ from: wed(6, 0), calendar: cal)
+ XCTAssertEqual(next, wed(7, 0), "invalid entries dropped, default 07:00 fires today")
+ }
+
+ /// The reported install: 04:45 on three days and 03:30 on two. The next fire after the default time
+ /// on a non-override day picks the override time on the next override day that is still ahead.
+ func testRealWorldMixedOverrides() {
+ // now = Wed 06:00, default 07:00, Tue=03:30 (3), Fri=04:45 (6)
+ // Wed has no override → default 07:00 today (still ahead).
+ let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [],
+ overrides: [3: 3 * 60 + 30, 6: 4 * 60 + 45],
+ from: wed(6, 0), calendar: cal)
+ XCTAssertEqual(next, wed(7, 0))
+ }
+
// MARK: Picker selection rules
func testWeekdayIsSelected_emptyMeansEveryDay() {
diff --git a/StrandTests/StandardHRLifecyclePersistenceTests.swift b/StrandTests/StandardHRLifecyclePersistenceTests.swift
new file mode 100644
index 0000000000..acb4dd2c89
--- /dev/null
+++ b/StrandTests/StandardHRLifecyclePersistenceTests.swift
@@ -0,0 +1,55 @@
+import XCTest
+import StrandAnalytics
+import WhoopProtocol
+import WhoopStore
+@testable import Strand
+
+@MainActor
+final class StandardHRLifecyclePersistenceTests: XCTestCase {
+ private final class CountingStore: StoreWriting {
+ private(set) var offeredHRRows = 0
+ private(set) var offeredRRRows = 0
+
+ func insert(_ streams: Streams, deviceId: String) async throws
+ -> (hr: Int, rr: Int, events: Int, battery: Int,
+ spo2: Int, skinTemp: Int, resp: Int, gravity: Int) {
+ offeredHRRows = streams.hr.count
+ offeredRRRows = streams.rr.count
+ // Deliberately differ from the offered counts: this is the store's conflict/dedup result.
+ return (0, 1, 0, 0, 0, 0, 0, 0)
+ }
+
+ func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws {}
+ }
+
+ func testBackgroundLifecycleFlushesSubThresholdStandardHRAndLogsStoreCounts() async {
+ let store = CountingStore()
+ var lines: [String] = []
+ let collector = Collector(
+ store: store,
+ deviceId: "test-strap",
+ log: { lines.append($0) },
+ now: { 1_750_000_000 }
+ )
+ let manager = BLEManager(state: LiveState(), collector: collector)
+
+ // Three accepted rows are intentionally below the 30-row cadence threshold. The invalid values
+ // prove that the host-receipt line reports Collector's accepted/rejected split, not input totals.
+ collector.ingestStandardHR(hr: 72, rr: [800, 100, 900], at: 1_750_000_000)
+ XCTAssertEqual(store.offeredHRRows + store.offeredRRRows, 0)
+
+ await manager.flushStandardHRForLifecycle(reason: .background)
+
+ XCTAssertEqual(store.offeredHRRows, 1)
+ XCTAssertEqual(store.offeredRRRows, 2)
+ XCTAssertTrue(lines.contains(
+ "standard-hr transport host-received hostUnixSec=1750000000"
+ + " acceptedHRRows=1 acceptedRRRows=2 rejectedHRRows=0 rejectedRRRows=1"
+ + " pendingHRRows=1 pendingRRRows=2"
+ ))
+ XCTAssertTrue(lines.contains(
+ "standard-hr transport flush-succeeded reason=background"
+ + " offeredHRRows=1 offeredRRRows=2 insertedHRRows=0 insertedRRRows=1"
+ ))
+ }
+}
diff --git a/StrandTests/StandardHeartRateTests.swift b/StrandTests/StandardHeartRateTests.swift
new file mode 100644
index 0000000000..948fb2f104
--- /dev/null
+++ b/StrandTests/StandardHeartRateTests.swift
@@ -0,0 +1,31 @@
+import XCTest
+import WhoopProtocol
+@testable import Strand
+
+final class StandardHeartRateTests: XCTestCase {
+ func testContactFlagsCoverAllCombinations() {
+ let cases: [(flags: UInt8, expected: StandardHRContact)] = [
+ (0x00, .unsupported),
+ (0x02, .unsupported),
+ (0x04, .supportedNotDetected),
+ (0x06, .supportedDetected),
+ ]
+
+ for test in cases {
+ XCTAssertEqual(
+ StandardHRContact.fromMeasurementFlags(test.flags),
+ test.expected,
+ "flags 0x\(String(test.flags, radix: 16))"
+ )
+ let parsed = StandardHeartRate.parse([test.flags, 72])
+ XCTAssertEqual(parsed?.contact, test.expected, "flags 0x\(String(test.flags, radix: 16))")
+ }
+ }
+
+ func testContactFlagsDoNotChangeExistingHeartRateOrRRParsing() {
+ let parsed = StandardHeartRate.parse([0x16, 72, 0x00, 0x04])
+ XCTAssertEqual(parsed?.hr, 72)
+ XCTAssertEqual(parsed?.rr, [1000])
+ XCTAssertEqual(parsed?.contact, .supportedDetected)
+ }
+}
diff --git a/StrandTests/StrapConsoleMirrorTests.swift b/StrandTests/StrapConsoleMirrorTests.swift
new file mode 100644
index 0000000000..8581f690f3
--- /dev/null
+++ b/StrandTests/StrapConsoleMirrorTests.swift
@@ -0,0 +1,49 @@
+import XCTest
+@testable import Strand
+
+/// Guards that the strap's own console narration actually REACHES the log.
+///
+/// The bug this exists for was not a decode bug and not a key bug — both of those were right. Offload
+/// frames are routed straight to the Backfiller and never reach `FrameRouter.handle`, and `CONSOLE_LOGS`
+/// is an offload type, so the handler that mirrors the text only ever saw the rare console frame arriving
+/// outside a sync. Every line worth having — `PullStats`, `History burst success`, `Historical Dump
+/// Complete` — is emitted DURING a sync, and was routed past the code meant to surface it.
+///
+/// Nothing caught that, because nothing asserted the call site existed. Unit tests passed and app-build
+/// was green on a feature that could not fire. So this test reads the source: the two offload branches
+/// (WHOOP4 and 5/MG are separate paths) must each invoke the carve-out.
+@MainActor
+final class StrapConsoleMirrorTests: XCTestCase {
+
+ private func bleManagerSource() throws -> String {
+ // StrandTests/ → repo root → Strand/BLE/BLEManager.swift
+ let here = URL(fileURLWithPath: #filePath)
+ let root = here.deletingLastPathComponent().deletingLastPathComponent()
+ let url = root.appendingPathComponent("Strand/BLE/BLEManager.swift")
+ return try String(contentsOf: url, encoding: .utf8)
+ }
+
+ /// Both offload branches must mirror the console, or one family goes silent mid-sync — which is the
+ /// exact shape of the original defect, just per-family instead of everywhere.
+ func testBothOffloadBranchesMirrorTheStrapConsole() throws {
+ let src = try bleManagerSource()
+ let calls = src.components(separatedBy: "router.mirrorStrapConsoleIfPresent(frame: frame)").count - 1
+ XCTAssertEqual(calls, 2,
+ "expected the console carve-out in BOTH offload branches (WHOOP4 + 5/MG); "
+ + "found \(calls). Without it the strap's narration is dropped for that family "
+ + "during exactly the sync it describes.")
+ }
+
+ /// The carve-out has to sit INSIDE the offload branch, beside the gesture one. If it drifted out to
+ /// the live path it would compile, pass the count check above, and still never fire during a sync.
+ func testTheCarveOutSitsBesideTheLiveGestureOne() throws {
+ let src = try bleManagerSource()
+ for part in src.components(separatedBy: "router.mirrorStrapConsoleIfPresent(frame: frame)").dropLast() {
+ let tail = String(part.suffix(400))
+ XCTAssertTrue(tail.contains("dispatchLiveGestureIfFresh"),
+ "each console carve-out must sit with the live-gesture carve-out inside the "
+ + "offload branch; one appears to have drifted to the live path, where it is "
+ + "redundant with `handle` and silent during a sync")
+ }
+ }
+}
diff --git a/StrandTests/StrapProvidesLineTests.swift b/StrandTests/StrapProvidesLineTests.swift
new file mode 100644
index 0000000000..c0b4afb9d5
--- /dev/null
+++ b/StrandTests/StrapProvidesLineTests.swift
@@ -0,0 +1,32 @@
+import XCTest
+@testable import Strand
+
+/// The line that says which scores can exist at all for the strap actually being worn.
+///
+/// Twin of the Kotlin `StrapProvidesLineTest`, and the expected strings are the Kotlin ones: these lines
+/// exist so an Android and an Apple report compare directly, so asserting each side against itself would
+/// prove only that each is self-consistent.
+final class StrapProvidesLineTests: XCTestCase {
+
+ func testAnUnbondedMGStreamsHeartDataAndNothingElse() {
+ XCTAssertEqual(
+ DebugDataDiagnostics.strapProvidesLine(hr: true, rr: true, motion: false, steps: false),
+ "Provides: HR yes · R-R yes · motion NO · steps NO (last 48h)"
+ )
+ }
+
+ func testAFullySyncedStrapProvidesAllFour() {
+ XCTAssertEqual(
+ DebugDataDiagnostics.strapProvidesLine(hr: true, rr: true, motion: true, steps: true),
+ "Provides: HR yes · R-R yes · motion yes · steps yes (last 48h)"
+ )
+ }
+
+ /// NO is capitalised and yes is not, deliberately: the absences are what the line exists to surface,
+ /// and a reader scanning a report should catch them without reading the labels.
+ func testAbsenceIsTheHalfThatStandsOut() {
+ let line = DebugDataDiagnostics.strapProvidesLine(hr: true, rr: false, motion: false, steps: true)
+ XCTAssertEqual(line.components(separatedBy: "NO").count - 1, 2, line)
+ XCTAssertEqual(line, "Provides: HR yes · R-R NO · motion NO · steps yes (last 48h)")
+ }
+}
diff --git a/StrandTests/TestBundleMetaTests.swift b/StrandTests/TestBundleMetaTests.swift
index 6521beb758..8dffc8abec 100644
--- a/StrandTests/TestBundleMetaTests.swift
+++ b/StrandTests/TestBundleMetaTests.swift
@@ -15,7 +15,8 @@ final class TestBundleMetaTests: XCTestCase {
profileStartedAt: "2026-06-26T07:12:00Z",
questionnaire: ["naps": "no"],
build: .init(channel: "AltStore", signed: false),
- storage: .init(dbBytes: 1024, rows: ["sleep_sessions": 12], rawCaptureBytes: 2048),
+ storage: .init(dbBytes: 1024, rows: ["sleep_sessions": 12], rawCaptureBytes: 2048,
+ rowBytes: ["sleep_sessions": 4096]),
redaction: "v2",
truncated: false)
}
diff --git a/StrandTests/TodayVitalCardTests.swift b/StrandTests/TodayVitalCardTests.swift
index fa68c191f9..2a159df12e 100644
--- a/StrandTests/TodayVitalCardTests.swift
+++ b/StrandTests/TodayVitalCardTests.swift
@@ -72,4 +72,43 @@ final class TodayVitalCardTests: XCTestCase {
let days = [day("2026-08-13", resp: 15.0)]
XCTAssertNil(Repository.lastRespDay(days: days, todayKey: "2026-08-13"))
}
+
+ // MARK: Steps calibration caption (#1816)
+
+ /// With no coefficient and no motion, the caption must say "No motion synced yet" — NOT the
+ /// phone-step-days countdown, because the motion half is the blocker and no number of phone-step
+ /// days will move the estimate or the fit. The old caption sent a field reporter to enter Apple
+ /// Health steps by hand expecting calibration to start, which it cannot without strap motion.
+ func testStepsCaptionNoMotionSaysNoMotionNotPhoneDays() {
+ let caption = TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 0,
+ hasBankedMotion: false, sampleDays: 0)
+ XCTAssertEqual(caption, "No motion synced yet")
+ }
+
+ /// With no coefficient but motion HAS been banked, the caption returns the engine's
+ /// needsMoreDays headline — the phone half is genuinely the missing half now.
+ func testStepsCaptionWithMotionReturnsPhoneDaysCountdown() {
+ let caption = TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 0,
+ hasBankedMotion: true, sampleDays: 0)
+ XCTAssertNotNil(caption)
+ XCTAssertNotEqual(caption, "No motion synced yet",
+ "with motion banked, the phone-step-days countdown is the honest message")
+ }
+
+ /// Once a coefficient exists (auto or manual), the caption is nil — a blank day is just a quiet
+ /// one below the motion floor, not a missing input, and there is nothing for the user to go do.
+ func testStepsCaptionNilOnceCalibrated() {
+ XCTAssertNil(TodayView.stepsCalibrationCaption(coefficient: 1.5, manualCoefficient: 0,
+ hasBankedMotion: false, sampleDays: 3))
+ XCTAssertNil(TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 2.0,
+ hasBankedMotion: false, sampleDays: 0))
+ }
+
+ /// The no-motion message takes precedence over the phone-step-days countdown even when some
+ /// sample days have been recorded, because the motion half is still the blocker.
+ func testStepsCaptionNoMotionPrecedenceOverSampleDays() {
+ let caption = TodayView.stepsCalibrationCaption(coefficient: 0, manualCoefficient: 0,
+ hasBankedMotion: false, sampleDays: 2)
+ XCTAssertEqual(caption, "No motion synced yet")
+ }
}
diff --git a/StrandTests/TodayWorkoutsWindowTests.swift b/StrandTests/TodayWorkoutsWindowTests.swift
new file mode 100644
index 0000000000..843f2e93b3
--- /dev/null
+++ b/StrandTests/TodayWorkoutsWindowTests.swift
@@ -0,0 +1,59 @@
+import XCTest
+import WhoopStore
+@testable import Strand
+
+/// #1702: the Today "Latest Workouts" window, pinned to Android's contract.
+///
+/// Android windows the feed to `today − 13` at start of day (`TodayScreen.recentCutoff`) and says so in
+/// the header. iOS showed the same section from `repo.workoutRows()`, whose default is `days: 4000` —
+/// effectively all-time — under a heading that says "Latest", with a trailing count of every workout
+/// ever recorded. Same account, opposite readings on the two platforms.
+///
+/// These pin the boundary rather than the happy path: an off-by-one here silently drops a session the
+/// other platform shows, which is exactly the class of drift that produced the issue.
+final class TodayWorkoutsWindowTests: XCTestCase {
+
+ /// Fixed clock so "13 days back, start of day" is not evaluated against a moving now.
+ private let now = Date(timeIntervalSince1970: 1_756_000_000) // 2025-08-24T02:26:40Z
+
+ /// WorkoutRow takes every field on purpose (#1444), so spell them all out.
+ private func row(startingAt ts: Int) -> WorkoutRow {
+ WorkoutRow(startTs: ts, endTs: ts + 1_800, sport: "Running", source: "whoop",
+ durationS: 1_800, energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil,
+ distanceM: nil, zonesJSON: nil, notes: nil, steps: nil)
+ }
+
+ private var cutoffTs: Int {
+ let cal = Calendar.current
+ return Int(cal.date(byAdding: .day, value: -13, to: cal.startOfDay(for: now))!.timeIntervalSince1970)
+ }
+
+ func testKeepsASessionExactlyOnTheCutoff() {
+ let kept = TodayView.recentWorkoutsFeed([row(startingAt: cutoffTs)], now: now)
+ XCTAssertEqual(kept.count, 1, "the cutoff instant is inside the window, matching Android's >=")
+ }
+
+ func testDropsTheSecondBeforeTheCutoff() {
+ let kept = TodayView.recentWorkoutsFeed([row(startingAt: cutoffTs - 1)], now: now)
+ XCTAssertTrue(kept.isEmpty)
+ }
+
+ func testDropsAnAllTimeSessionThatUsedToShow() {
+ // The behaviour the issue described: a session from months ago listed under "Latest".
+ let threeMonthsAgo = Int(now.timeIntervalSince1970) - 90 * 86_400
+ XCTAssertTrue(TodayView.recentWorkoutsFeed([row(startingAt: threeMonthsAgo)], now: now).isEmpty)
+ }
+
+ func testKeepsTodaysSession() {
+ let kept = TodayView.recentWorkoutsFeed([row(startingAt: Int(now.timeIntervalSince1970))], now: now)
+ XCTAssertEqual(kept.count, 1)
+ }
+
+ func testPreservesInputOrderAndDropsOnlyTheStale() {
+ let recentA = Int(now.timeIntervalSince1970) - 3_600
+ let recentB = Int(now.timeIntervalSince1970) - 7_200
+ let stale = cutoffTs - 86_400
+ let kept = TodayView.recentWorkoutsFeed([row(startingAt: recentA), row(startingAt: stale), row(startingAt: recentB)], now: now)
+ XCTAssertEqual(kept.map(\.startTs), [recentA, recentB], "filtering must not reorder the feed")
+ }
+}
diff --git a/StrandTests/UnitFormatterTests.swift b/StrandTests/UnitFormatterTests.swift
index 0fce9073aa..faa67a75a1 100644
--- a/StrandTests/UnitFormatterTests.swift
+++ b/StrandTests/UnitFormatterTests.swift
@@ -76,6 +76,13 @@ final class UnitFormatterTests: XCTestCase {
XCTAssertEqual(UnitFormatter.distanceFromKilometers(12.4, system: .imperial), "7.7 mi")
}
+ func testSpeedFollowsExerciseDistanceSystem() {
+ XCTAssertEqual(UnitFormatter.speedFromKilometersPerHour(10, system: .metric), "10.0 km/h")
+ XCTAssertEqual(UnitFormatter.speedFromKilometersPerHour(10, system: .imperial), "6.2 mph")
+ XCTAssertNil(UnitFormatter.speedFromKilometersPerHour(nil, system: .metric))
+ XCTAssertNil(UnitFormatter.speedFromKilometersPerHour(-1, system: .imperial))
+ }
+
// MARK: - Mass formatting
func testMassFromKilograms() {
@@ -118,6 +125,56 @@ final class UnitFormatterTests: XCTestCase {
XCTAssertEqual(UnitFormatter.temperatureDeltaFromCelsius(0.6, unit: .celsius), "0.6 °C")
}
+ /// The Double-returning delta converter and the String one must not drift: callers that need
+ /// locale-aware decimals (the illness-signal label formats through AppLanguage.activeLocale) use the
+ /// Double form, and a second copy of the 9/5 rule is exactly what that split invites.
+ func testDeltaConverterAgreesWithTheFormatter() {
+ XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(0.6), 1.08, accuracy: 1e-9)
+ XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(-4.2), -7.56, accuracy: 1e-9)
+ XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(0), 0, accuracy: 1e-9)
+ for c in stride(from: -5.0, through: 5.0, by: 0.25) {
+ XCTAssertEqual(
+ String(format: "%.1f", UnitFormatter.celsiusDeltaToFahrenheit(c)) + " °F",
+ UnitFormatter.temperatureDeltaFromCelsius(c, unit: .fahrenheit),
+ "diverged at \(c) °C")
+ }
+ }
+
+ /// A delta must never pick up the +32: at 0 the two conversions differ by exactly the offset, which
+ /// is what made a −4.2 °C deviation read as a fever once before (#111/#622).
+ func testDeltaAndAbsoluteConversionsAreDistinct() {
+ XCTAssertEqual(UnitFormatter.celsiusToFahrenheit(0), 32.0, accuracy: 1e-9)
+ XCTAssertEqual(UnitFormatter.celsiusDeltaToFahrenheit(0), 0.0, accuracy: 1e-9)
+ }
+
+ /// The illness-signal phrase. Expected strings generated by compiling SkinTempDisplay standalone
+ /// with swiftc and pasted verbatim — the oracle rule, not arithmetic done by eye.
+ func testSkinTempSignalPhraseHandlesBothModes() {
+ // Deviation (live BLE): signed, "Δ" chip, ×9/5 with NO offset.
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: false, locale: nil), "+0.7 Δ°C")
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: true, locale: nil), "+1.3 Δ°F")
+ // Absolute (CSV / Apple Health): unsigned, bare chip, +32 offset applied.
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(34.1, fahrenheit: false, locale: nil), "34.1 °C")
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(34.1, fahrenheit: true, locale: nil), "93.4 °F")
+ }
+
+ /// The 20 °C boundary decides the mode, and it belongs to the ABSOLUTE side.
+ func testSkinTempSignalPhraseSplitsAtTwentyDegrees() {
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(19.9, fahrenheit: false, locale: nil), "+19.9 Δ°C")
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(20.0, fahrenheit: false, locale: nil), "20.0 °C")
+ }
+
+ /// The whole reason the number is formatted here instead of by SkinTempDisplay: the banner passes
+ /// AppLanguage.activeLocale, and a German reader must see "0,7" rather than "0.7".
+ func testSkinTempSignalPhraseHonoursTheLocalesDecimalSeparator() {
+ let de = Locale(identifier: "de_DE")
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: false, locale: de), "+0,7 Δ°C")
+ XCTAssertEqual(UnitFormatter.skinTempSignalPhrase(34.1, fahrenheit: false, locale: de), "34,1 °C")
+ XCTAssertEqual(
+ UnitFormatter.skinTempSignalPhrase(0.7, fahrenheit: true, locale: Locale(identifier: "fr_FR")),
+ "+1,3 Δ°F")
+ }
+
// #111/#622: skin_temp holds EITHER a signed deviation (v < 20 °C) or an absolute reading (v >= 20 °C).
// A DEVIATION converts ×9/5 with NO +32 (the absolute formula turned a −4.2 °C deviation into the
// nonsense "24.4 °F") and is labelled as a signed baseline delta — "Δ°C" / "Δ°F", sign always shown —
@@ -148,6 +205,14 @@ final class UnitFormatterTests: XCTestCase {
XCTAssertEqual(UnitPrefs.resolveTemperature(system: .metric, override: "fahrenheit"), .fahrenheit)
}
+ func testDistanceSystemFallsBackToLegacyPreference() {
+ XCTAssertEqual(UnitPrefs.resolveDistance(system: .metric, override: ""), .metric)
+ XCTAssertEqual(UnitPrefs.resolveDistance(system: .imperial, override: ""), .imperial)
+ XCTAssertEqual(UnitPrefs.resolveDistance(system: .imperial, override: "metric"), .metric)
+ XCTAssertEqual(UnitPrefs.resolveDistance(system: .metric, override: "imperial"), .imperial)
+ XCTAssertEqual(UnitPrefs.resolveDistance(system: .imperial, override: "unknown"), .imperial)
+ }
+
// MARK: - Unit labels
func testUnitLabels() {
diff --git a/StrandTests/UpdateAvailabilityTests.swift b/StrandTests/UpdateAvailabilityTests.swift
new file mode 100644
index 0000000000..c49ff9f48d
--- /dev/null
+++ b/StrandTests/UpdateAvailabilityTests.swift
@@ -0,0 +1,161 @@
+import XCTest
+@testable import Strand
+
+/// The #1659 automatic update check.
+///
+/// iOS cannot auto-update a sideloaded build — no API lets an app install or re-sign an `.ipa` — so the
+/// most NOOP can do is notice a release and say so. These rules decide when it may look, and when the
+/// result is worth a row in the Updates inbox.
+final class UpdateAvailabilityTests: XCTestCase {
+
+ private let day = UpdateAvailability.checkInterval
+
+ // MARK: - when it may look
+
+ /// Off by default, and off means SILENT: no request, no line, no trace. That is what keeps the
+ /// feature compatible with the project's offline promise.
+ func testDisabledNeverChecks() {
+ XCTAssertFalse(UpdateAvailability.shouldCheckNow(enabled: false, lastCheckedAt: 0, now: 1_000_000))
+ XCTAssertFalse(UpdateAvailability.shouldCheckNow(enabled: false, lastCheckedAt: 1, now: 1_000_000))
+ }
+
+ /// Turning the toggle on should answer during THAT session, not tomorrow.
+ func testANeverCheckedInstallIsDueImmediately() {
+ XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: 0, now: 1_000_000))
+ }
+
+ func testItWaitsOutTheInterval() {
+ let t = 1_000_000.0
+ XCTAssertFalse(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t + day - 1))
+ XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t + day))
+ XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t + day * 3))
+ }
+
+ /// A clock that moves BACKWARDS — timezone edit, NTP correction, a restored backup — would otherwise
+ /// park the next check arbitrarily far in the future. Treating it as due self-heals on the next write.
+ func testAClockGoingBackwardsDoesNotStrandTheCheck() {
+ let t = 1_000_000.0
+ XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t - 5))
+ XCTAssertTrue(UpdateAvailability.shouldCheckNow(enabled: true, lastCheckedAt: t, now: t - day * 400))
+ }
+
+ // MARK: - when it is worth saying
+
+ func testItPostsOnlyForANewerVersion() {
+ XCTAssertTrue(UpdateAvailability.shouldPost(latest: "10.7.0", current: "10.6.0", lastPostedVersion: nil))
+ XCTAssertFalse(UpdateAvailability.shouldPost(latest: "10.6.0", current: "10.6.0", lastPostedVersion: nil))
+ XCTAssertFalse(UpdateAvailability.shouldPost(latest: "10.5.0", current: "10.6.0", lastPostedVersion: nil))
+ }
+
+ /// ONCE PER VERSION. On iOS acting means AltStore or a re-sign, so a user may reasonably read the row
+ /// and do nothing — and an app that repeats itself daily about something the user has already seen
+ /// teaches people to ignore the bell, which costs more than the feature is worth.
+ func testItNeverNagsAboutAVersionAlreadyPosted() {
+ XCTAssertFalse(UpdateAvailability.shouldPost(latest: "10.7.0", current: "10.6.0",
+ lastPostedVersion: "10.7.0"))
+ // …but the NEXT release is new news again.
+ XCTAssertTrue(UpdateAvailability.shouldPost(latest: "10.8.0", current: "10.6.0",
+ lastPostedVersion: "10.7.0"))
+ }
+
+ /// The counters are versions, not booleans: a user who skipped 10.7.0 and is still on 10.6.0 must
+ /// still hear about 10.8.0.
+ func testSkippingAReleaseDoesNotSilenceTheNextOne() {
+ XCTAssertTrue(UpdateAvailability.shouldPost(latest: "11.0.0", current: "10.6.0",
+ lastPostedVersion: "10.7.0"))
+ }
+
+ // MARK: - the inbox row
+
+ /// `.newVersion` must be INFORMATIONAL so it dedupes and can be evicted. It cannot spam on its own,
+ /// but a user who ignores several releases should not have them crowd out the actionable rows.
+ func testTheRowIsInformationalAndDistinctFromWhatsNew() {
+ let item = UpdateItem(kind: .newVersion, title: "t", message: "m")
+ XCTAssertEqual(item.kind, .newVersion)
+ XCTAssertNotEqual(item.kind, .whatsNew)
+ // Round-trips through the persisted store's Codable rawValue.
+ XCTAssertEqual(UpdateItem.Kind(rawValue: "newVersion"), .newVersion)
+ }
+
+ // MARK: - pruning a stale announcement
+
+ /// The row says "10.7.0 is available". Once the user installs 10.7.0 that sentence is false, and it
+ /// sits beside the What's New row for the same version — an app telling you to get what you have.
+ func testTheAnnouncementIsPrunedOnceInstalled() {
+ XCTAssertTrue(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "10.7.0", current: "10.7.0"))
+ // Overshot it (a user who jumped straight to 10.8.0) — equally stale.
+ XCTAssertTrue(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "10.7.0", current: "10.8.0"))
+ }
+
+ /// Still behind: the row is still TRUE and must survive, or the announcement would delete itself on
+ /// the very next launch and the feature would do nothing at all.
+ func testAnAnnouncementStillAheadSurvives() {
+ XCTAssertFalse(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "10.7.0", current: "10.6.0"))
+ }
+
+ func testNothingAnnouncedIsNothingToPrune() {
+ XCTAssertFalse(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: nil, current: "10.6.0"))
+ XCTAssertFalse(UpdateAvailability.shouldPruneAnnouncement(lastPostedVersion: "", current: "10.6.0"))
+ }
+
+ // MARK: - assembling the row body
+
+ /// The COPY is localized at the platform edge now, so what is pinned here is the ASSEMBLY: order, the
+ /// single space between sentences, the blank line before notes, and the trimming. A first cut tested
+ /// the English strings byte-for-byte against Kotlin — the wrong property, and one that would have
+ /// stayed green while the row rendered in English inside a translated inbox.
+ func testComposeJoinsSentencesWithASingleSpace() {
+ XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: "B.", notes: ""), "A. B.")
+ }
+
+ func testComposeOmitsAnAbsentOrEmptySideloadSentence() {
+ XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: nil, notes: ""), "A.")
+ XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: "", notes: ""), "A.")
+ }
+
+ /// Release notes are a block, not a sentence: blank line before, and trimmed — GitHub bodies arrive
+ /// with padding.
+ func testComposeAppendsTrimmedNotesAfterABlankLine() {
+ XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: nil, notes: " N. "),
+ "A.\n\nN.")
+ XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: "B.", notes: "N."),
+ "A. B.\n\nN.")
+ }
+
+ /// Whitespace-only notes must not leave a trailing blank line hanging in the row.
+ func testComposeTreatsBlankNotesAsNone() {
+ XCTAssertEqual(UpdateAvailability.composeMessage(body: "A.", sideload: nil, notes: " \n "), "A.")
+ }
+
+ // MARK: - the default
+
+ /// An UNSET pref must resolve to the same answer the Settings toggle shows. The launch check reads
+ /// UserDefaults directly and the toggle reads @AppStorage; both fall back to `defaultEnabled`, and if
+ /// one were changed without the other the switch would say one thing while the app did another.
+ func testAnUnsetPrefMatchesWhatTheToggleWouldShow() {
+ let key = UpdateWatch.Keys.enabled
+ let saved = UserDefaults.standard.object(forKey: key)
+ defer {
+ if let saved { UserDefaults.standard.set(saved, forKey: key) }
+ else { UserDefaults.standard.removeObject(forKey: key) }
+ }
+ UserDefaults.standard.removeObject(forKey: key)
+ XCTAssertEqual(UpdateWatch.isEnabled, UpdateAvailability.defaultEnabled)
+ }
+
+ /// An explicit choice always wins over the default, in BOTH directions — otherwise flipping the
+ /// shipped default would silently override people who had already opted out.
+ func testAnExplicitChoiceOverridesTheDefault() {
+ let key = UpdateWatch.Keys.enabled
+ let saved = UserDefaults.standard.object(forKey: key)
+ defer {
+ if let saved { UserDefaults.standard.set(saved, forKey: key) }
+ else { UserDefaults.standard.removeObject(forKey: key) }
+ }
+ UserDefaults.standard.set(false, forKey: key)
+ XCTAssertFalse(UpdateWatch.isEnabled)
+ UserDefaults.standard.set(true, forKey: key)
+ XCTAssertTrue(UpdateWatch.isEnabled)
+ }
+}
+
diff --git a/StrandTests/VitalReadingsTableTests.swift b/StrandTests/VitalReadingsTableTests.swift
index 74fca102fc..6fef98c56e 100644
--- a/StrandTests/VitalReadingsTableTests.swift
+++ b/StrandTests/VitalReadingsTableTests.swift
@@ -87,4 +87,39 @@ final class VitalReadingsTableTests: XCTestCase {
)
XCTAssertEqual(rows.first?.value, "72")
}
+
+ // MARK: - #1942: the Explorer pairs a unit-BEARING formatter with an EMPTY unit
+
+ /// Every test above passes `spo2Format`, a unit-LESS closure, which is the contract this function was
+ /// written to and the one all of Android's call sites follow. The Explorer cannot follow it: its
+ /// formatter is `MetricDescriptor.format`, which already ends in the unit, and the CONVERTED one —
+ /// there is no unit-less iOS formatter that also converts kg→lb / °C→°F / 0–100→0–21. So it pairs that
+ /// formatter with an empty unit, and this pins both halves of that pairing.
+ ///
+ /// Passing the descriptor's stored `unit` alongside it is what rendered "33 % %" in the readings table
+ /// while the hero and the stat tiles, which render the formatter directly, stayed correct (#1942).
+ func testExplorerFormatterAlreadyCarriesTheUnitSoTheUnitParameterIsEmpty() throws {
+ let spo2 = try XCTUnwrap(MetricCatalog.metric(key: "spo2", source: strap))
+ let explorerFormat: (Double) -> String = { spo2.format($0, system: .metric, temperature: .celsius) }
+
+ // Half one: the Explorer's own formatter is unit-BEARING, unlike `spo2Format` above.
+ XCTAssertEqual(explorerFormat(97), "97 %")
+
+ // Half two: paired with an empty unit, the row carries exactly one.
+ let rows = vitalReadingRows(readings: spo2Readings(), unit: "", strapDeviceId: strap,
+ now: now, format: explorerFormat)
+ XCTAssertEqual(rows.first?.value, "97 %")
+ }
+
+ /// The function itself is not at fault, and this says so: given the pairing the Explorer used to pass,
+ /// doubling is the CORRECT output. That is why the fix is at the call site and why Android, whose
+ /// twin appends identically, was never affected. Kept as the counter-case to the test above so a
+ /// future reader restoring `unit: metric.unit` sees what it does.
+ func testAUnitBearingFormatterPlusAUnitDoublesItWhichIsWhyTheCallSitePassesEmpty() throws {
+ let spo2 = try XCTUnwrap(MetricCatalog.metric(key: "spo2", source: strap))
+ let rows = vitalReadingRows(readings: spo2Readings(), unit: spo2.unit, strapDeviceId: strap,
+ now: now,
+ format: { spo2.format($0, system: .metric, temperature: .celsius) })
+ XCTAssertEqual(rows.first?.value, "97 % %")
+ }
}
diff --git a/StrandTests/Whoop5BatteryBackfillThrottleTests.swift b/StrandTests/Whoop5BatteryBackfillThrottleTests.swift
index 1b464af84b..863a005d67 100644
--- a/StrandTests/Whoop5BatteryBackfillThrottleTests.swift
+++ b/StrandTests/Whoop5BatteryBackfillThrottleTests.swift
@@ -119,4 +119,24 @@ final class Whoop5BatteryBackfillThrottleTests: XCTestCase {
// The first read of a connection still always fires, charging or not.
XCTAssertTrue(BLEManager.shouldPollWhoop5Battery(lastReadAt: nil, now: now, charging: false))
}
+
+ // MARK: - #1953: unbonded 5/MG battery read parity
+
+ /// #1953: an unbonded 5/MG (the #1635 suppressed-hello case) gets a periodic 0x2A19 battery read on
+ /// Android's `keepAliveFire` but not on iOS — the tick runs (via `keepAliveMayRun`'s
+ /// `bonded && .whoop5` branch) but the read lives inside `enableLiveNotifications`, which is
+ /// `didBond`-gated. The fix reuses THIS throttle on the unbonded path so the two platforms read at
+ /// the same cadence without doubling the rate on a bonded strap. These pin the throttle's contract
+ /// for that new caller: the gate is the same, only the call site differs.
+ func testUnbondedBatteryReadUsesSameThrottle() {
+ // First read of a connection — always fires, bonded or not.
+ XCTAssertTrue(BLEManager.shouldPollWhoop5Battery(lastReadAt: nil))
+ // Under the floor — blocked, so an unbonded 5/MG is not re-read every 30 s tick.
+ let now = Date()
+ let last = now.addingTimeInterval(-29)
+ XCTAssertFalse(BLEManager.shouldPollWhoop5Battery(lastReadAt: last, now: now))
+ // At the floor — fires, matching Android's ~60 s cadence.
+ let last2 = now.addingTimeInterval(-BLEManager.whoop5BatteryReadMinIntervalSeconds)
+ XCTAssertTrue(BLEManager.shouldPollWhoop5Battery(lastReadAt: last2, now: now))
+ }
}
diff --git a/StrandTests/Whoop5BatteryPollGuardTests.swift b/StrandTests/Whoop5BatteryPollGuardTests.swift
new file mode 100644
index 0000000000..baf9cb9e86
--- /dev/null
+++ b/StrandTests/Whoop5BatteryPollGuardTests.swift
@@ -0,0 +1,66 @@
+import XCTest
+@testable import Strand
+
+/// #1948: the 5/MG keep-alive must not send battery commands the allowlist refuses.
+///
+/// Both commands that block used to send are rejected on a 5/MG before they leave the app: the send
+/// allowlist has no clause for `.getBatteryLevel` at all, and admits `.getBatteryPackInfo` only while a
+/// user-initiated probe is in flight. So a 5/MG paid two dead sends and two skip lines per tick, under a
+/// comment claiming the pack "rides the SAME cadence as the strap's own gauge".
+///
+/// A 5/MG needs neither: its percent comes from the 0x2A19 read `enableLiveNotifications` drives off this
+/// same keep-alive, and its pack charge from the pushed pack-info event (109).
+///
+/// Pinned against the SOURCE, the way `DeviceRawSourceParityTests` pins its oracle, because the send sits
+/// in a keep-alive body no unit test can drive. The Kotlin twin guard is in `ChargingAndReleaseTest`.
+final class Whoop5BatteryPollGuardTests: XCTestCase {
+
+ private func managerSource() throws -> String {
+ let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
+ return try String(contentsOf: root.appendingPathComponent("Strand/BLE/BLEManager.swift"),
+ encoding: .utf8)
+ }
+
+ /// Whitespace-normalised on both sides. A Swift multiline literal strips indentation to its closing
+ /// delimiter, so a literal written here could never carry the source's own leading spaces, and
+ /// comparing raw text would fail for a reason that has nothing to do with the guard.
+ private func normalised(_ s: String) -> String {
+ s.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
+ }
+
+ /// The poll is family-guarded, and the guard is on the SAME statement as the send — a nearby `if`
+ /// that merely reads well would not stop the traffic.
+ func testTheBatteryPollIsGuardedAwayFromWhoop5() throws {
+ let src = try normalised(managerSource())
+ let expected = normalised("""
+ if selectedModel.deviceFamily != .whoop5,
+ BLEManager.batteryPollDue(tick: keepAliveTick, charging: state.charging == true) {
+ send(.getBatteryLevel, payload: [])
+ }
+ """)
+ XCTAssertTrue(src.contains(expected),
+ "the keep-alive battery poll must be guarded away from the 5/MG (#1948)")
+ }
+
+ /// Nothing may send the pack opcode on a cadence. The user-initiated probe still may, so this asserts
+ /// the absence in the keep-alive rather than in the file, which would forbid the probe too.
+ func testNoCadencedPackInfoSendSurvives() throws {
+ let src = try managerSource()
+ // The WHOLE keep-alive, not just the battery block inside it. Anchoring at the battery comment
+ // covered only what follows it, so a pack send added earlier in the same function would have
+ // slipped past while the assertion still read as if it guarded the tick. The Kotlin twin was
+ // widened for the mirror-image reason, and a control insertion proved it there.
+ guard let start = src.range(of: "private func keepAliveFire() {"),
+ let end = src.range(of: "private func startBackfillTimer") else {
+ return XCTFail("the keep-alive function or its next landmark was not found")
+ }
+ // Comments in this block name the opcode while explaining why it is not sent, so judge the CODE
+ // only — the same rule the Kotlin twin follows. Without this the guard fails on its own rationale.
+ let block = String(src[start.lowerBound.. 5 { WidgetTelemetry.noteDeclined() } else { WidgetTelemetry.noteReloaded(now: at) }
+ }
+ let s = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(660))
+ XCTAssertEqual(s.admitted, 11)
+ XCTAssertEqual(s.reloaded, 5)
+ XCTAssertEqual(s.declined, 5)
+ // Five reloads in a ten-minute steady window is 30/h, not the 60/h the publishes would claim.
+ XCTAssertEqual(s.reloadsPerHour ?? 0, 30, accuracy: 0.001)
+ }
+
+ /// The startup burst must not be quoted as a rate. The snapshot's fields arrive one by one at
+ /// launch and each publishes on the spot, so a launch produces activity the once-a-minute throttle
+ /// had nothing to do with. An Android capture reported six in a hundred seconds as 215/h against a
+ /// steady state of about sixty.
+ func testAStartupBurstIsNotQuotedAsARate() {
+ for offset in [0.0, 2, 5, 9, 30, 95] {
+ WidgetTelemetry.noteAdmitted(now: t0.addingTimeInterval(offset))
+ WidgetTelemetry.noteReloaded(now: t0.addingTimeInterval(offset))
+ }
+ let s = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(100))
+ XCTAssertEqual(s.admitted, 6)
+ XCTAssertNil(s.reloadsPerHour, "a burst is not a rate")
+ XCTAssertTrue(s.render().contains("steady"), s.render())
+ }
+
+ /// The withheld-rate message has to name the window it waits on. Phrasing it as a minimum UPTIME
+ /// was wrong on Android: publishes can be sparse enough that the steady window opens late, so the
+ /// line would sit twenty minutes in still claiming it needed six.
+ func testAWithheldRateNamesTheSteadyWindow() {
+ WidgetTelemetry.noteAdmitted(now: t0)
+ WidgetTelemetry.noteAdmitted(now: t0.addingTimeInterval(19 * 60))
+ let line = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(20 * 60)).render()
+ XCTAssertTrue(line.contains("over 20m"), line)
+ XCTAssertTrue(line.contains("steady 1m of 5m"), line)
+ }
+
+ /// The absence of widget activity is itself an answer to a drain report, so it must be stated
+ /// rather than rendered as an empty line.
+ func testASessionWithNoPublishesSaysSo() {
+ XCTAssertEqual(WidgetTelemetry.snapshot(now: t0).render(),
+ "Widgets: no publishes this app session")
+ }
+
+ /// The rate is a diagnostics field read by eye and by grep, so it must not pick up a locale's
+ /// decimal comma.
+ func testTheRateFormatsWithADot() {
+ WidgetTelemetry.noteAdmitted(now: t0)
+ for m in 1...10 {
+ let at = t0.addingTimeInterval(Double(m) * 60)
+ WidgetTelemetry.noteAdmitted(now: at)
+ WidgetTelemetry.noteReloaded(now: at)
+ }
+ let line = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(660)).render()
+ XCTAssertTrue(line.contains("60.0/h"), line)
+ XCTAssertFalse(line.contains("60,0/h"), line)
+ }
+
+ /// The widget-removed export is half of the comparison the counters exist for, so a reload with
+ /// nothing installed must not read as one. Android had exactly this bug; iOS never checks whether
+ /// a widget is placed before calling `reloadAllTimelines`, so without this the figure would be
+ /// just as blind here.
+ func testAReloadWithNoWidgetInstalledIsNotCountedAsOne() {
+ WidgetTelemetry.noteWidgetsInstalled(false)
+ WidgetTelemetry.noteAdmitted(now: t0)
+ for m in 1...10 {
+ let at = t0.addingTimeInterval(Double(m) * 60)
+ WidgetTelemetry.noteAdmitted(now: at)
+ WidgetTelemetry.noteNoWidget()
+ }
+ let s = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(660))
+ XCTAssertEqual(s.admitted, 11)
+ XCTAssertEqual(s.reloaded, 0)
+ XCTAssertEqual(s.noWidget, 10)
+ XCTAssertEqual(s.reloadsPerHour ?? -1, 0, accuracy: 0.001)
+ XCTAssertTrue(s.render().contains("10 with no widget installed"), s.render())
+ }
+
+ /// Unknown must count as installed. Over-reporting reloads is the safe direction for a figure
+ /// whose whole purpose is to show a cost: a wrong "nothing was spent" is the one answer that would
+ /// end an investigation early.
+ func testPresenceDefaultsToInstalledBeforeWidgetKitHasAnswered() {
+ XCTAssertTrue(WidgetTelemetry.widgetsInstalled)
+ WidgetTelemetry.noteWidgetsInstalled(false)
+ XCTAssertFalse(WidgetTelemetry.widgetsInstalled)
+ WidgetTelemetry.resetForTest()
+ XCTAssertTrue(WidgetTelemetry.widgetsInstalled, "reset returns it to unknown, not to false")
+ }
+}
diff --git a/StrandTests/WindDownPerDayOverrideTests.swift b/StrandTests/WindDownPerDayOverrideTests.swift
index 2533fe805a..5b47c3e0cc 100644
--- a/StrandTests/WindDownPerDayOverrideTests.swift
+++ b/StrandTests/WindDownPerDayOverrideTests.swift
@@ -80,4 +80,60 @@ final class WindDownPerDayOverrideTests: XCTestCase {
WindDownNudge.setWakeOverride(weekday: 9, minutes: 8 * 60) // no weekday 9
XCTAssertFalse(WindDownNudge.hasPerDayOverrides)
}
+
+ // MARK: - #1863: day shift and weekday pinning
+
+ func testShiftedWeekday_wrapsThroughWeekEnd() {
+ // Sunday (1) − 1 → Saturday (7); Monday (2) − 1 → Sunday (1); no shift is identity.
+ XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 1, by: -1), 7)
+ XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 2, by: -1), 1)
+ XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 3, by: -1), 2)
+ XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: 7, by: -1), 6)
+ for weekday in 1...7 {
+ XCTAssertEqual(WindDownNudge.shiftedWeekday(weekday: weekday, by: 0), weekday)
+ }
+ }
+
+ func testNudgeDayShift_earlyWake_isPreviousDay() {
+ // Tuesday 03:30 wake, default 8h need + 30m lead: raw = 210 − 480 − 30 = −300 → shift −1.
+ WindDownNudge.setWakeMinutes(7 * 60)
+ WindDownNudge.setWakeOverride(weekday: 3, minutes: 3 * 60 + 30) // Tuesday 03:30
+ XCTAssertEqual(WindDownNudge.nudgeDayShift(forWeekday: 3), -1)
+ // The wrapped minute is 19:00 the previous evening.
+ XCTAssertEqual(WindDownNudge.nudgeMinuteOfDay(forWeekday: 3), 19 * 60)
+ }
+
+ func testNudgeDayShift_normalWake_isPreviousDay() {
+ // 07:00 wake, default 8h need + 30m lead: raw = 420 − 480 − 30 = −90 → shift −1.
+ WindDownNudge.setWakeMinutes(7 * 60)
+ XCTAssertEqual(WindDownNudge.nudgeDayShift(forWeekday: 3), -1)
+ }
+
+ func testNudgeDayShift_lateWake_isSameDay() {
+ // 09:00 wake, default 8h need + 30m lead: raw = 540 − 480 − 30 = 30 → shift 0.
+ WindDownNudge.setWakeMinutes(9 * 60)
+ XCTAssertEqual(WindDownNudge.nudgeDayShift(forWeekday: 3), 0)
+ }
+
+ func testEarlyWakeNudge_pinnedToPreviousDay() {
+ // #1863 scenario: Tuesday (weekday 3) 03:30 wake → nudge at 19:00 on Monday (weekday 2).
+ WindDownNudge.setWakeMinutes(7 * 60)
+ WindDownNudge.setWakeOverride(weekday: 3, minutes: 3 * 60 + 30) // Tuesday 03:30
+ let shift = WindDownNudge.nudgeDayShift(forWeekday: 3)
+ let minute = WindDownNudge.nudgeMinuteOfDay(forWeekday: 3)
+ let nudgeWeekday = WindDownNudge.shiftedWeekday(weekday: 3, by: shift)
+ XCTAssertEqual(shift, -1)
+ XCTAssertEqual(minute, 19 * 60) // 19:00
+ XCTAssertEqual(nudgeWeekday, 2) // Monday, not Tuesday
+ }
+
+ func testEarlyWakeOnSunday_pinnedToSaturday() {
+ // Sunday (weekday 1) 03:30 wake → nudge at 19:00 on Saturday (weekday 7).
+ WindDownNudge.setWakeMinutes(7 * 60)
+ WindDownNudge.setWakeOverride(weekday: 1, minutes: 3 * 60 + 30) // Sunday 03:30
+ let shift = WindDownNudge.nudgeDayShift(forWeekday: 1)
+ let nudgeWeekday = WindDownNudge.shiftedWeekday(weekday: 1, by: shift)
+ XCTAssertEqual(shift, -1)
+ XCTAssertEqual(nudgeWeekday, 7) // Saturday, wrapping the week end
+ }
}
diff --git a/StrandiOS/App/AppModel+iOS.swift b/StrandiOS/App/AppModel+iOS.swift
index 5b706e3751..56e7589033 100644
--- a/StrandiOS/App/AppModel+iOS.swift
+++ b/StrandiOS/App/AppModel+iOS.swift
@@ -2,9 +2,10 @@
import Foundation
extension AppModel {
- /// Execute any actions queued by App Intents while the app was suspended (mark moment, buzz).
- /// Call when the app becomes active.
- func drainPendingIntents() {
+ /// Execute any actions queued by App Intents while the app was suspended (mark moment, buzz,
+ /// ask coach). Call when the app becomes active. The optional `router` lets the ask-coach
+ /// intent navigate to the Coach tab after sending the question.
+ func drainPendingIntents(router: NavRouter? = nil) {
for item in PendingIntents.drain() {
switch item.action {
case .markMoment: markMoment(at: item.date ?? Date())
@@ -12,6 +13,16 @@ extension AppModel {
// The one-shot routine sends the confirmed pattern + RUN_ALARM sequence, acked, so a
// busy just-foregrounded BLE link can't silently drop it.
case .buzz: buzzStrapOnce()
+ // K9: "Ask Coach" via Siri — send the queued question to the Coach engine and navigate
+ // to the Coach tab so the user sees the response. The question is consumed from a
+ // dedicated key (one at a time).
+ case .askCoach:
+ if let question = PendingIntents.consumeCoachQuestion() {
+ router?.openCoach()
+ Task { @MainActor in
+ await coach.send(question)
+ }
+ }
}
}
}
diff --git a/StrandiOS/App/CoachVoiceInput.swift b/StrandiOS/App/CoachVoiceInput.swift
new file mode 100644
index 0000000000..de61060be5
--- /dev/null
+++ b/StrandiOS/App/CoachVoiceInput.swift
@@ -0,0 +1,241 @@
+import Foundation
+import SwiftUI
+#if canImport(UIKit)
+import UIKit
+import Speech
+#endif
+
+/// K4: On-device speech-to-text wrapper for the Coach composer (iOS only).
+///
+/// Design contract (see PRD-K4):
+/// - **No raw audio egress.** Recognition is requested with `requiresOnDeviceRecognition = true`
+/// wherever the locale supports it. If a locale ONLY supports server recognition, voice is
+/// **disabled** for that locale rather than falling back to server transcription — server
+/// transcription would ship audio off-device, breaking the offline posture.
+/// - The microphone button lives in the shared `CoachView` composer; on macOS this type is a
+/// no-op (`isSupported` is `false`, all methods are inert) so the shared file keeps compiling.
+/// - Only the resulting TEXT ever reaches the provider, via the same channel a typed question
+/// already uses. No new network path is introduced.
+///
+/// Usage from `CoachView`:
+/// 1. `voice.isSupported` gates whether the mic button is shown at all.
+/// 2. `voice.authorizationStatus` drives the button's enabled / "open Settings" state.
+/// 3. `voice.startTranscribing(partial:)` begins a session; `partial` is called on the main
+/// actor with the live transcript.
+/// 4. `voice.stopTranscribing(completion:)` finalizes and returns the final text.
+@MainActor
+final class CoachVoiceInput: ObservableObject {
+
+ /// Whether voice input is available on this device + locale at all.
+ static var isSupported: Bool {
+ #if canImport(UIKit)
+ return SFSpeechRecognizer.authorizationStatus() != .restricted
+ && SFSpeechRecognizer.self != nil
+ && SFSpeechRecognizer(locale: Locale.current) != nil
+ #else
+ return false
+ #endif
+ }
+
+ #if canImport(UIKit)
+ /// Current Speech framework authorization status, mapped to a simple enum the UI can switch on.
+ var authorization: AuthorizationState {
+ switch SFSpeechRecognizer.authorizationStatus() {
+ case .authorized: return .authorized
+ case .denied: return .denied
+ case .restricted: return .unavailable
+ case .notDetermined: return .notDetermined
+ @unknown default: return .unavailable
+ }
+ }
+ #else
+ var authorization: AuthorizationState { .unavailable }
+ #endif
+
+ /// Whether a recognition session is currently recording.
+ @Published private(set) var isRecording: Bool = false
+
+ /// The latest partial transcript (live, while recording).
+ @Published var partialTranscript: String = ""
+
+ /// A short human-readable status for the disabled-state tooltip (e.g. "On-device speech not
+ /// available for en-GB"). `nil` when voice is fully available.
+ @Published var statusMessage: String?
+
+ /// On-device support for the current locale. If `false`, voice is disabled rather than
+ /// falling back to server recognition (which would be raw-audio egress).
+ private static var localeSupportsOnDevice: Bool {
+ #if canImport(UIKit)
+ guard let recognizer = SFSpeechRecognizer(locale: Locale.current) else { return false }
+ return recognizer.supportsOnDeviceRecognition
+ #else
+ return false
+ #endif
+ }
+
+ /// True when voice can be used right now: supported, authorized, and locale supports on-device.
+ var canUseVoice: Bool {
+ guard Self.isSupported, authorization == .authorized, Self.localeSupportsOnDevice else {
+ return false
+ }
+ return true
+ }
+
+ /// Request Speech + Microphone authorization. Safe to call repeatedly; the system no-ops if
+ /// already decided. `completion` fires on the main actor.
+ func requestAuthorization(completion: @escaping (AuthorizationState) -> Void) {
+ #if canImport(UIKit)
+ // Speech auth first; mic auth piggybacks on the same prompt flow but is a separate grant.
+ SFSpeechRecognizer.requestAuthorization { speechStatus in
+ Task { @MainActor in
+ if speechStatus != .authorized {
+ completion(self.mapSpeech(speechStatus))
+ return
+ }
+ // Mic permission is requested lazily by AVAudioEngine on iOS — but we ask up front
+ // so the user sees one consolidated prompt and the button state is correct before
+ // the first tap.
+ self.requestMicPermission { micGranted in
+ completion(micGranted ? .authorized : .denied)
+ }
+ }
+ }
+ #else
+ completion(.unavailable)
+ #endif
+ }
+
+ /// Begin a recognition session. `partial` is invoked on the main actor with the live transcript
+ /// as it streams in. No-op (calls `partial` with "") on macOS / unsupported locales.
+ func startTranscribing(partial: @escaping (String) -> Void) {
+ #if canImport(UIKit)
+ guard canUseVoice else {
+ statusMessage = Self.localeSupportsOnDevice
+ ? String(localized: "Voice input unavailable. Check microphone permission in Settings.")
+ : String(localized: "On-device speech not available for \(Locale.current.identifier).")
+ return
+ }
+ stopTranscribing(completion: { _ in })
+ partialTranscript = ""
+ statusMessage = nil
+ beginSession(partial: partial)
+ #else
+ partial("")
+ #endif
+ }
+
+ /// Stop the active session and return the finalized transcript via `completion` (main actor).
+ func stopTranscribing(completion: @escaping (String) -> Void) {
+ #if canImport(UIKit)
+ guard isRecording else {
+ completion(partialTranscript)
+ return
+ }
+ finishSession(completion: completion)
+ #else
+ completion("")
+ #endif
+ }
+
+ // MARK: - Private (iOS only)
+
+ #if canImport(UIKit)
+ private var audioEngine: AVAudioEngine?
+ private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
+ private var recognitionTask: SFSpeechRecognitionTask?
+
+ private func mapSpeech(_ status: SFSpeechRecognizerAuthorizationStatus) -> AuthorizationState {
+ switch status {
+ case .authorized: return .authorized
+ case .denied: return .denied
+ case .restricted: return .unavailable
+ case .notDetermined: return .notDetermined
+ @unknown default: return .unavailable
+ }
+ }
+
+ private func requestMicPermission(completion: @escaping (Bool) -> Void) {
+ let session = AVAudioSession.sharedInstance()
+ session.requestRecordPermission { granted in
+ Task { @MainActor in completion(granted) }
+ }
+ }
+
+ private func beginSession(partial: @escaping (String) -> Void) {
+ let audioEngine = AVAudioEngine()
+ self.audioEngine = audioEngine
+
+ let request = SFSpeechAudioBufferRecognitionRequest()
+ // K4 hard constraint: on-device ONLY. If the locale doesn't support it, voice is disabled
+ // upstream — but we set the flag defensively here too so a regression can't slip audio out.
+ request.requiresOnDeviceRecognition = true
+ request.shouldReportPartialResults = true
+ self.recognitionRequest = request
+
+ guard let recognizer = SFSpeechRecognizer(locale: Locale.current) else {
+ statusMessage = String(localized: "On-device speech not available for \(Locale.current.identifier).")
+ return
+ }
+
+ let task = recognizer.recognitionTask(with: request) { [weak self] result, error in
+ Task { @MainActor in
+ guard let self else { return }
+ if let result {
+ let text = result.bestTranscription.formattedString
+ self.partialTranscript = text
+ partial(text)
+ }
+ if error != nil || (result?.isFinal ?? false) {
+ self.finishSession(completion: { _ in })
+ }
+ }
+ }
+ self.recognitionTask = task
+
+ do {
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(.record, mode: .measurement, options: .duckOthers)
+ try session.setActive(true, options: .notifyOthersOnDeactivation)
+
+ let inputNode = audioEngine.inputNode
+ let recordingFormat = inputNode.outputFormat(forBus: 0)
+ inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
+ request.append(buffer)
+ }
+
+ audioEngine.prepare()
+ try audioEngine.start()
+ isRecording = true
+ } catch {
+ statusMessage = String(localized: "Couldn't start the microphone: \(error.localizedDescription)")
+ finishSession(completion: { _ in })
+ }
+ }
+
+ private func finishSession(completion: @escaping (String) -> Void) {
+ if let audioEngine {
+ audioEngine.stop()
+ audioEngine.inputNode.removeTap(onBus: 0)
+ self.audioEngine = nil
+ }
+ recognitionRequest?.endAudio()
+ recognitionRequest = nil
+ recognitionTask?.cancel()
+ recognitionTask = nil
+ try? AVAudioSession.sharedInstance().setActive(
+ false, options: .notifyOthersOnDeactivation
+ )
+ let final = partialTranscript
+ isRecording = false
+ completion(final)
+ }
+ #endif
+
+ /// Simplified authorization state the UI switches on (decoupled from SFSpeech symbols on macOS).
+ enum AuthorizationState {
+ case notDetermined
+ case authorized
+ case denied
+ case unavailable
+ }
+}
diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift
index 52b15fadbd..943a49e484 100644
--- a/StrandiOS/App/RootTabView.swift
+++ b/StrandiOS/App/RootTabView.swift
@@ -6,6 +6,16 @@ import StrandDesign
/// natural analogue is a `TabView` with the most-used screens as tabs and everything else under a
/// "More" list. Every screen is the same `StrandDesign`-built view the macOS app uses.
struct RootTabView: View {
+ /// #1841: shared with Android by NAME and meaning, not by storage — the two platforms keep their own
+ /// stores, exactly as the Clock format setting does.
+ ///
+ /// Default FALSE here while Android defaults true, and the divergence is deliberate. Apple's forums
+ /// report `.tabBarMinimizeBehavior(.onScrollDown)` failing to trigger in tabs built on
+ /// `NavigationStack(path:)` — which is every primary tab in this file, bound deliberately so a tab
+ /// root can pop and re-scroll. So this may well be inert on our structure, and defaulting ON would
+ /// advertise a behaviour that never happens. Off until someone confirms it on an iOS 26 device.
+ @AppStorage("noop.bottomBarAutoHide") private var bottomBarAutoHide = false
+
/// External entry points must wait until the mandatory first-run gates have completed. The root owns
/// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate.
let homeScreenQuickActionsEnabled: Bool
@@ -32,12 +42,12 @@ struct RootTabView: View {
/// root view alive, so an at-root re-tap keeps scroll position and never re-runs `.task`
/// (#198; the #197 resetID/`.id()` rebuild reset both). Requires the tab roots' first-hop
/// links to push `TabRoute`/`MoreDestination` VALUES — closure-destination links bypass the path.
- @State private var tabPaths: [NavigationPath] = Array(repeating: NavigationPath(), count: 4)
+ @State private var tabPaths: [NavigationPath] = Array(repeating: NavigationPath(), count: 5)
/// One scroll-to-top token per tab. Bumped when the user re-taps the active tab while it's ALREADY
/// at its root — the other half of the iOS convention #197/#198 left unserved (an at-root re-tap was
/// a no-op). Threaded into each tab's root via `\.scrollToTopSignal`; ScreenScaffold / LiquidTodayView
/// scroll to their top anchor when their tab's token changes.
- @State private var scrollTop: [Int] = Array(repeating: 0, count: 4)
+ @State private var scrollTop: [Int] = Array(repeating: 0, count: 5)
/// Which More-tab groups are expanded (S2). Insights + Body stay open at rest; Data + App collapse to
/// just their header until tapped. Persisted (#860 item 2): the user's open/closed choice must SURVIVE
/// leaving and re-entering the More tab (and relaunch), not reset to the seed every visit. Backed by an
@@ -95,7 +105,7 @@ struct RootTabView: View {
guard selectedTab != 0 else { return }
let dx = v.translation.width, dy = v.translation.height
guard abs(dx) > 60, abs(dx) > abs(dy) * 1.6 else { return }
- let next = min(3, max(0, selectedTab + (dx < 0 ? 1 : -1)))
+ let next = min(4, max(0, selectedTab + (dx < 0 ? 1 : -1)))
if next != selectedTab {
withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = next }
}
@@ -110,9 +120,16 @@ struct RootTabView: View {
tab(todayTabRoot, "Today", "square.grid.2x2", path: $tabPaths[0], scrollSignal: scrollTop[0]).tag(0)
tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis", path: $tabPaths[1], scrollSignal: scrollTop[1]).tag(1)
tab(SleepView(), "Sleep", "bed.double", path: $tabPaths[2], scrollSignal: scrollTop[2]).tag(2)
- moreTab(path: $tabPaths[3], scrollSignal: scrollTop[3]).tag(3)
+ // K3: Coach promoted to a top-level tab (was behind the More list). The sparkles icon
+ // matches the More-tab row and the macOS sidebar entry.
+ tab(CoachView(), "Coach", "sparkles", path: $tabPaths[3], scrollSignal: scrollTop[3]).tag(3)
+ moreTab(path: $tabPaths[4], scrollSignal: scrollTop[4]).tag(4)
}
.tint(StrandPalette.accent)
+ // #1841: the same "Hide bar when scrolling" preference Android drives its own bar with. Here the
+ // system owns the behaviour — iOS 26's tab bar MINIMISES to a pill on scroll down rather than
+ // sliding away entirely, so this is the platform's read of the same intent, not a copy of ours.
+ .noopTabBarAutoHide(bottomBarAutoHide)
// Tab crossfade — README §Motion: ~240ms opacity swap between tab roots, global calm
// easing cubic-bezier(0.22,1,0.36,1).
.animation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24), value: selectedTab)
@@ -172,6 +189,11 @@ struct RootTabView: View {
case .insightsHub, .labBook, .fusedRecord, .rhythm:
routedPillar = dest
router.requestedDestination = nil
+ case .coach:
+ // K3: Coach is now a top-level tab (tag 3) — switch to it directly instead of
+ // presenting it as a pillar sheet.
+ withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = 3 }
+ router.requestedDestination = nil
case .trends:
// Trends is a primary tab on iPhone (not a pillar sheet) — switch to it.
withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = 1 }
@@ -187,6 +209,11 @@ struct RootTabView: View {
// so a deep-link lands on the Today tab where that entry lives.
withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selectedTab = 0 }
router.requestedDestination = nil
+ case .coach:
+ // #1862: the Today Coach launcher hands its question here. Coach is a pillar sheet on
+ // iPhone, the same as the Insights hub, so route it that way rather than switching tabs.
+ routedPillar = dest
+ router.requestedDestination = nil
case .journal:
// The #627 Today journal widget opens the journal through the quick-action Journal sheet
// (InsightsView), matching the FAB's "Log journal" action. Calm sheet easing.
@@ -248,6 +275,8 @@ struct RootTabView: View {
case .fusedRecord: FusedRecordHost()
case .rhythm: RhythmHost(onClose: { routedPillar = nil })
case .devices: DevicesView()
+ // K5: the scheduled morning-brief notification's tap-through target.
+ case .coach: CoachView()
// .trends is never presented as a pillar sheet on iPhone (it's a primary tab — the
// requestedDestination handler switches `selectedTab` instead), but the switch must stay
// exhaustive. Fall back to Trends inside the sheet host if it ever arrives here.
@@ -261,6 +290,9 @@ struct RootTabView: View {
// .journal opens through the quick-action Journal sheet (handled above); this keeps the
// switch exhaustive and falls back to the journal's Insights host if it ever reaches here.
case .journal: InsightsView()
+ // #1862: Coach IS presented here — the launcher sheet routes to it as a pillar, so unlike
+ // the fallbacks above this arm is the real destination, not a safety net.
+ case .coach: CoachView()
}
}
// The Trends/Today fallbacks above emit TabRoute value pushes (#198), which need a
@@ -387,7 +419,7 @@ struct RootTabView: View {
moreSection("Insights") {
MoreRow("What Moves You", "wand.and.sparkles", .insightsHub)
MoreRow("Intelligence", "brain.head.profile", .intelligence)
- MoreRow("Coach", "sparkles", .coach)
+ // K3: Coach promoted to a top-level tab — no longer listed under More.
MoreRow("Insights", "lightbulb.fill", .insights)
MoreRow("Explore", "square.grid.2x2.fill", .explore)
MoreRow("Compare", "rectangle.split.2x1.fill", .compare)
@@ -686,3 +718,25 @@ private struct QuickActionSheet: View {
}
#endif
+
+/// #1841: apply the iOS 26 tab-bar minimise behaviour, doing nothing on older systems.
+///
+/// The availability branch is deliberately the ONLY branch. `RootTabView` already documents what happens
+/// when a condition that flips at runtime wraps this `TabView`: #519 put two states in separate
+/// `_ConditionalContent` branches, and every navigation rebuilt the whole subtree, resetting `@State`
+/// inside the tab roots — scroll offsets, chart ranges, expanded sections.
+///
+/// So the preference must NOT select between branches. It selects the modifier's ARGUMENT, while the
+/// availability check — fixed for the life of the process — is what picks a branch. Toggling the setting
+/// changes a value, never the view's identity.
+extension View {
+ @ViewBuilder
+ func noopTabBarAutoHide(_ enabled: Bool) -> some View {
+ if #available(iOS 26.0, *) {
+ // `.onScrollDown` minimises to a pill on downward scroll; `.never` pins it fully visible.
+ self.tabBarMinimizeBehavior(enabled ? .onScrollDown : .never)
+ } else {
+ self
+ }
+ }
+}
diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift
index 366bc066e0..8f49e8653c 100644
--- a/StrandiOS/App/StrandiOSApp.swift
+++ b/StrandiOS/App/StrandiOSApp.swift
@@ -23,7 +23,7 @@ struct StrandiOSApp: App {
@StateObject private var watch = WatchSessionBridge()
/// Shared cross-screen navigation hook (e.g. Live → Devices). The iOS shell (`RootTabView`)
/// observes it and presents the Devices manager.
- @StateObject private var router = NavRouter()
+ @StateObject private var router: NavRouter
@State private var liveActivity = LiveActivityController()
@Environment(\.scenePhase) private var scenePhase
/// Appearance preference (System/Light/Dark). Default follows the OS; the Settings picker writes it.
@@ -66,6 +66,10 @@ struct StrandiOSApp: App {
// is open, so a user testing the wind-down reminder with NOOP foregrounded sees nothing. Register
// before the first scene so any early-fired notification is presented.
UNUserNotificationCenter.current().delegate = NotificationPresenter.shared
+ // K5: tapping a scheduled morning-brief notification routes to Coach via the shared NavRouter.
+ let router = NavRouter()
+ _router = StateObject(wrappedValue: router)
+ NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() }
let model = AppModel()
_model = StateObject(wrappedValue: model)
// #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a
@@ -106,6 +110,45 @@ struct StrandiOSApp: App {
}
}
+ /// The Shortcut-import alert's presentation binding, hoisted OUT of the `.alert` chain.
+ ///
+ /// An inline `Binding(get:set:)` is two untyped closures the solver must infer in place, on a
+ /// modifier chain that had already blown the type-check budget. Declaring it as a `Binding`
+ /// property replaces all of that with one known type. Hoisting the message alone was not enough —
+ /// the build failed again at the same modifier, which is why this one is here too.
+ private var healthImportAlertPresented: Binding {
+ Binding(
+ get: { model.pendingShortcutHealthImport != nil },
+ set: { showing in
+ if !showing { model.cancelPendingHealthImport() }
+ }
+ )
+ }
+
+ /// The Shortcut-import alert's buttons, hoisted for the same reason as the binding above.
+ @ViewBuilder
+ private var healthImportAlertButtons: some View {
+ Button("Import") { model.confirmPendingHealthImport() }
+ Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() }
+ }
+
+ /// The Shortcut-import alert's message, hoisted OUT of the `.alert` chain.
+ ///
+ /// Not a style preference. This closure — an `if let` around two interpolated `Text`s — sits on a
+ /// modifier chain that grew past the Swift type-checker's budget, and the build failed with
+ /// "unable to type-check this expression in reasonable time" pointing at `} message: {`. The
+ /// expression did not change; the chain around it did. Hoisting a sub-expression into its own
+ /// declaration gives the solver a fixed type to work from instead of one more unknown in a chain
+ /// it is already struggling with.
+ @ViewBuilder
+ private var healthImportAlertMessage: some View {
+ if let pending = model.pendingShortcutHealthImport {
+ Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.")
+ } else {
+ Text("A Shortcut wants to add data to the Apple Health import source.")
+ }
+ }
+
var body: some Scene {
WindowGroup {
iOSRootView()
@@ -226,20 +269,10 @@ struct StrandiOSApp: App {
model.handleHealthImportURL(url)
}
}
- .alert("Import Apple Health data?", isPresented: Binding(
- get: { model.pendingShortcutHealthImport != nil },
- set: { showing in
- if !showing { model.cancelPendingHealthImport() }
- }
- )) {
- Button("Import") { model.confirmPendingHealthImport() }
- Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() }
+ .alert("Import Apple Health data?", isPresented: healthImportAlertPresented) {
+ healthImportAlertButtons
} message: {
- if let pending = model.pendingShortcutHealthImport {
- Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.")
- } else {
- Text("A Shortcut wants to add data to the Apple Health import source.")
- }
+ healthImportAlertMessage
}
// Bring the watch link up once at launch (WCSession ignores a redundant activate), then
// push the first snapshot so a watch that's already on-wrist gets current scores without
@@ -260,7 +293,7 @@ struct StrandiOSApp: App {
// safe no-op until the user opts in.
.onChange(of: scenePhase) { _, phase in
if phase == .active {
- model.drainPendingIntents()
+ model.drainPendingIntents(router: router)
// Re-arm the strap's smart alarm on foreground: the firmware alarm is a single instant
// and iOS can't re-arm it while suspended, so it would otherwise fire once and stop.
model.applySmartAlarm()
@@ -400,6 +433,16 @@ private struct iOSRootView: View {
// Seed the current What's New into the Updates inbox (idempotent per version) so the bell
// collects it even if the user dismisses the auto sheet.
UpdateStore.shared.seedWhatsNewIfNeeded()
+ // #1659: iOS cannot auto-update a sideloaded build - no API lets an app install or re-sign an
+ // .ipa - so the most NOOP can do is NOTICE a release and say so.
+ //
+ // Gated on the SAME condition as showWhatsNewIfDue above, and the Android hook. This matters
+ // now that the check is on by default: without it a brand-new install would reach the network
+ // during first run, before the Terms gate the user has not accepted yet. While the default was
+ // off, nothing made that visible.
+ if onboarded && acceptedTerms == Terms.currentVersion {
+ UpdateWatch.runIfDue(currentVersion: UpdateWatch.installedVersion, sideloadHint: true)
+ }
}
.onChange(of: acceptedTerms) { _, _ in showWhatsNewIfDue() }
}
diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift
index de533e31e5..d251578554 100644
--- a/StrandiOS/Health/HealthKitBridge.swift
+++ b/StrandiOS/Health/HealthKitBridge.swift
@@ -1,6 +1,7 @@
#if os(iOS)
import Foundation
import HealthKit
+import CoreLocation
import UIKit
import WhoopStore
import StrandAnalytics
@@ -94,6 +95,11 @@ final class HealthKitBridge: ObservableObject {
for id in HealthKitBridge.quantityReadIds { if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } }
if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) }
s.insert(HKObjectType.workoutType())
+ // #1205: workout routes (GPS) so imported workouts can show their map. Routes are separate
+ // samples associated with each HKWorkout; without this in readTypes the route query returns
+ // empty and the imported workout shows distance but no map — same as today, so the addition
+ // is strictly additive.
+ s.insert(HKSeriesType.workoutRoute())
return s
}
@@ -768,6 +774,14 @@ final class HealthKitBridge: ObservableObject {
func attempt(_ op: () async throws -> Void) async {
do { try await op() } catch { if firstError == nil { firstError = error } }
}
+ // #1503: one-off sweep to clear records stranded under the OLD device-id-keyed scheme.
+ // The old keys embedded the active strap id (`noop:::`), which
+ // became unreachable after a re-pair. The new keys drop the id segment, so the normal
+ // delete-then-write can never find the old records. This sweep deletes ALL of our prior
+ // records in the write-back window by `HKSource.default()` + date range (the same pattern
+ // the HR path uses), then the normal writes re-add them under the new keys. Runs once,
+ // gated by a UserDefaults flag, BEFORE the new-key writes so nothing is lost.
+ await attempt { try await migrateStrandedHealthRecords(fromTs: fromTs, nowTs: nowTs) }
await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions) }
await attempt { try await writeSleep(sessions: sessions) }
await attempt { try await writeHeartRate(whoopStore: whoopStore, fromTs: fromTs, nowTs: nowTs) }
@@ -775,6 +789,75 @@ final class HealthKitBridge: ObservableObject {
if let firstError { throw firstError }
}
+ /// UserDefaults key for the #1503 stranded-records sweep completion set. Stores the set of
+ /// type ids whose sweep delete has SUCCEEDED, so a type that was unauthorized or whose delete
+ /// failed is retried on a later write-back run rather than abandoned. Per-type (not a single
+ /// boolean) because partial authorization is a supported state: a user who granted sleep but
+ /// declined workouts must still get the stranded workouts swept once they later grant workouts,
+ /// and a `deleteObjects` that threw must not read as "already done".
+ private static let strandedRecordsSweptKey = "hkStrandedRecordsSweptTypes.v1"
+
+ /// Stable type ids used in the per-type swept set. The quantity identifiers are the
+ /// `HKQuantityTypeIdentifier` raw values; sleep and workout use fixed labels so the set is
+ /// round-trippable through UserDefaults without depending on HealthKit type object identity.
+ private static let sleepSweepTypeId = "sleepAnalysis"
+ private static let workoutSweepTypeId = "workout"
+
+ /// One-off migration (#1503): delete ALL of our prior Apple Health records in the write-back
+ /// window that were written under the old device-id-keyed scheme. The old keys embedded the
+ /// active strap id, which became unreachable after a re-pair; the new keys drop the id segment.
+ /// This sweep uses `HKSource.default()` + date range (like the HR path) to reach records the
+ /// new key-based delete predicate can never find. Runs BEFORE the new-key writes so the fresh
+ /// batch re-adds everything under the stable keys.
+ ///
+ /// Completion is tracked PER-TYPE (not a single boolean): a type is marked swept only when its
+ /// `deleteObjects` call returned a result (succeeded, even if it deleted zero records). A type
+ /// that was unauthorized or whose delete threw is left un-swept, so a later authorization grant
+ /// or a successful retry finishes the migration instead of abandoning the stranded records.
+ /// The decision helpers live in `HealthWriteback` so they are unit-tested without HealthKit.
+ private func migrateStrandedHealthRecords(fromTs: Int, nowTs: Int) async throws {
+ let defaults = UserDefaults.standard
+ let swept: Set = Set(defaults.stringArray(forKey: Self.strandedRecordsSweptKey) ?? [])
+ let bySource = HKQuery.predicateForObjects(from: HKSource.default())
+ let byDate = HKQuery.predicateForSamples(
+ withStart: Date(timeIntervalSince1970: TimeInterval(fromTs)),
+ end: Date(timeIntervalSince1970: TimeInterval(nowTs) + 60),
+ options: [])
+ let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [bySource, byDate])
+ var succeededThisRun: Set = []
+ // Vitals: each quantity type that carried an id-keyed external UUID.
+ for id in Self.quantityWriteIds {
+ let typeId = id.rawValue
+ guard !swept.contains(typeId),
+ let type = HKQuantityType.quantityType(forIdentifier: id),
+ store.authorizationStatus(for: type) == .sharingAuthorized else { continue }
+ // `try?` returns nil on throw — a failure is NOT recorded as swept, so it retries next run.
+ if (try? await store.deleteObjects(of: type, predicate: pred)) != nil {
+ succeededThisRun.insert(typeId)
+ }
+ }
+ // Sleep.
+ if !swept.contains(Self.sleepSweepTypeId),
+ let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis),
+ store.authorizationStatus(for: sleep) == .sharingAuthorized {
+ if (try? await store.deleteObjects(of: sleep, predicate: pred)) != nil {
+ succeededThisRun.insert(Self.sleepSweepTypeId)
+ }
+ }
+ // Workouts.
+ if !swept.contains(Self.workoutSweepTypeId),
+ store.authorizationStatus(for: .workoutType()) == .sharingAuthorized {
+ if (try? await store.deleteObjects(of: .workoutType(), predicate: pred)) != nil {
+ succeededThisRun.insert(Self.workoutSweepTypeId)
+ }
+ }
+ // Persist only the types that actually succeeded this run; the rest stay pending.
+ if !succeededThisRun.isEmpty {
+ let updated = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: succeededThisRun)
+ defaults.set(Array(updated), forKey: Self.strandedRecordsSweptKey)
+ }
+ }
+
/// The nightly vitals write (the original write-back), now stamped at the day's wake time when
/// that day has a sleep session — a real timestamp inside the night the value describes, instead
/// of a fabricated noon. Keys are unchanged, so re-stamped samples replace their noon ancestors.
@@ -807,7 +890,11 @@ final class HealthKitBridge: ObservableObject {
func add(_ id: HKQuantityTypeIdentifier, _ unit: HKUnit, _ value: Double, _ day: String, _ at: Date) {
guard let type = HKQuantityType.quantityType(forIdentifier: id),
store.authorizationStatus(for: type) == .sharingAuthorized else { return }
- let key = "noop:\(noopDeviceId):\(id.rawValue):\(day)"
+ // #1503: the key carries NO device-id segment. The previous scheme embedded the active
+ // strap id, which is not durable — a re-pair changed it and stranded every prior record
+ // in Apple Health as unreachable duplicates. The natural key (`noop::`)
+ // matches the Android twin and is stable across strap lifecycle changes.
+ let key = HealthWriteback.appleHealthVitalKey(metricId: id.rawValue, day: day)
let sample = HKQuantitySample(
type: type,
quantity: .init(unit: unit, doubleValue: value),
@@ -870,10 +957,12 @@ final class HealthKitBridge: ObservableObject {
/// `.asleepUnspecified` block instead of fabricated stage placement.
///
/// Dedup: every sample of a night carries `HKMetadataKeyExternalUUID =
- /// noop::sleep:` keyed by the group's EARLIEST fragment's immutable detected
+ /// noop:sleep:` keyed by the group's EARLIEST fragment's immutable detected
/// onset (a user edit moves the span, never the key). The delete predicate carries EVERY
/// fragment's key, so a night previously written as two entries fully clears when it becomes
- /// one; delete-then-write scoped to our own `HKSource`, like the vitals.
+ /// one; delete-then-write scoped to our own `HKSource`, like the vitals. The key carries NO
+ /// device-id segment (#1503): the active strap id is not durable, and embedding it stranded
+ /// every prior record as unreachable duplicates after a re-pair.
private func writeSleep(sessions: [CachedSleepSession]) async throws {
guard let type = HKObjectType.categoryType(forIdentifier: .sleepAnalysis),
store.authorizationStatus(for: type) == .sharingAuthorized else { return }
@@ -889,9 +978,9 @@ final class HealthKitBridge: ObservableObject {
var samples: [HKCategorySample] = []
var keys: [String] = []
for entry in HealthWriteback.mergedSleepPlan(groups: groups) {
- let key = "noop:\(noopDeviceId):sleep:\(entry.keyStartTs)"
+ let key = HealthWriteback.appleHealthSleepKey(startTs: entry.keyStartTs)
let meta = [HKMetadataKeyExternalUUID: key]
- keys.append(contentsOf: entry.allKeyStartTs.map { "noop:\(noopDeviceId):sleep:\($0)" })
+ keys.append(contentsOf: entry.allKeyStartTs.map { HealthWriteback.appleHealthSleepKey(startTs: $0) })
samples.append(HKCategorySample(type: type, value: HKCategoryValueSleepAnalysis.inBed.rawValue,
start: Date(timeIntervalSince1970: TimeInterval(entry.spanStart)),
end: Date(timeIntervalSince1970: TimeInterval(entry.spanEnd)),
@@ -985,8 +1074,11 @@ final class HealthKitBridge: ObservableObject {
/// sports. Workouts whose source is `apple-health` are EXCLUDED — those were imported FROM
/// Health, and writing them back would duplicate the user's own Apple Watch/gym-app workouts.
///
- /// Dedup: `HKMetadataKeyExternalUUID = noop::workout:` in the workout
- /// metadata; delete-then-write scoped to our own source, like sleep and the vitals.
+ /// Dedup: `HKMetadataKeyExternalUUID = noop:workout:` in the workout
+ /// metadata; delete-then-write scoped to our own source, like sleep and the vitals. The key
+ /// carries NO device-id segment (#1503): the active strap id is not durable, and embedding it
+ /// stranded every prior workout as unreachable duplicates after a re-pair. Matches the Android
+ /// twin's `noop-workout-` `clientRecordId`.
private func writeWorkouts(whoopStore: WhoopStore, fromTs: Int, toTs: Int) async throws {
guard store.authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return }
let mine = (try? await whoopStore.workouts(deviceId: noopDeviceId, from: fromTs, to: toTs, limit: 500)) ?? []
@@ -998,7 +1090,7 @@ final class HealthKitBridge: ObservableObject {
let rows = byKey.values.sorted { $0.startTs < $1.startTs }
guard !rows.isEmpty else { return }
- func key(_ row: WorkoutRow) -> String { "noop:\(noopDeviceId):workout:\(row.startTs)" }
+ func key(_ row: WorkoutRow) -> String { HealthWriteback.appleHealthWorkoutKey(startTs: row.startTs) }
let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [
HKQuery.predicateForObjects(from: HKSource.default()),
HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID,
@@ -1293,16 +1385,21 @@ final class HealthKitBridge: ObservableObject {
HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate),
Self.notNoopAuthored,
])
- return await withCheckedContinuation { (cont: CheckedContinuation<[WorkoutRow], Never>) in
+ // #1205: collect the HKWorkout objects alongside the rows so we can fetch their GPS routes
+ // after the sample query completes. Routes are separate HKWorkoutRoute samples associated
+ // with each workout; they cannot be read inside the sample query's completion handler
+ // (HealthKit does not allow nested queries on the same store), so we hold the workouts and
+ // fetch routes in a second pass below.
+ let workoutsAndRows: [(HKWorkout, WorkoutRow)] = await withCheckedContinuation { (cont: CheckedContinuation<[(HKWorkout, WorkoutRow)], Never>) in
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let q = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate,
limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
- var rows: [WorkoutRow] = []
+ var pairs: [(HKWorkout, WorkoutRow)] = []
for case let workout as HKWorkout in samples ?? [] {
let startTs = Int(workout.startDate.timeIntervalSince1970)
let endTs = max(Int(workout.endDate.timeIntervalSince1970), startTs)
let duration = workout.duration > 0 ? workout.duration : Double(endTs - startTs)
- rows.append(WorkoutRow(
+ pairs.append((workout, WorkoutRow(
startTs: startTs,
endTs: endTs,
sport: Self.sportName(workout.workoutActivityType),
@@ -1314,12 +1411,67 @@ final class HealthKitBridge: ObservableObject {
strain: nil,
distanceM: workout.totalDistance?.doubleValue(for: .meter()),
zonesJSON: nil,
- notes: nil, steps: nil))
+ notes: nil, steps: nil)))
}
- cont.resume(returning: rows)
+ cont.resume(returning: pairs)
}
store.execute(q)
}
+ // #1205: fetch and store GPS routes for each workout. Best-effort — a route read failure
+ // (permission not granted, no route data, HealthKit error) leaves the workout intact with
+ // no map, which is exactly the pre-change behaviour. Stored via RouteStore under the same
+ // (startTs, sport) key WorkoutDetailView loads from, so no UI change is needed.
+ // Collected first and written ONCE. `RouteStore.store` rewrites the entire capped map on every
+ // call, which is right for the recorder's single call at workout end but quadratic here: a
+ // 500-workout first import would decode and re-encode a 400-entry map 500 times, on the order of
+ // a gigabyte of JSON through UserDefaults. `storeAll` applies the same eviction, once.
+ var importedRoutes: [(route: WorkoutRoute, startTs: Int, sport: String)] = []
+ for (workout, row) in workoutsAndRows {
+ if let route = await Self.fetchWorkoutRoute(for: workout, store: store),
+ route.count >= 2 {
+ let polyline = RouteMath.encode(route)
+ let distanceM = RouteMath.totalMeters(route)
+ importedRoutes.append((WorkoutRoute(polyline: polyline, distanceM: distanceM),
+ row.startTs, row.sport))
+ }
+ }
+ RouteStore.storeAll(importedRoutes)
+ return workoutsAndRows.map { $0.1 }
+ }
+
+ /// #1205: fetch the GPS route (list of `RouteMath.LatLng`) for a single `HKWorkout`.
+ /// Queries `HKWorkoutRoute` samples overlapping the workout's time range, then collects all
+ /// `CLLocation` waypoints from each route via `HKWorkoutRouteQuery`. Returns `nil` when there
+ /// is no route, the user has not granted route read access, or HealthKit reports an error —
+ /// all of which are graceful skips (the workout imports without a map, same as today).
+ nonisolated static func fetchWorkoutRoute(for workout: HKWorkout, store: HKHealthStore) async -> [RouteMath.LatLng]? {
+ let routeType = HKSeriesType.workoutRoute()
+ let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
+ // First: query for HKWorkoutRoute samples associated with this workout.
+ let routes: [HKWorkoutRoute] = await withCheckedContinuation { (cont: CheckedContinuation<[HKWorkoutRoute], Never>) in
+ let q = HKSampleQuery(sampleType: routeType, predicate: predicate,
+ limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, _ in
+ let routeSamples = (samples as? [HKWorkoutRoute]) ?? []
+ cont.resume(returning: routeSamples)
+ }
+ store.execute(q)
+ }
+ guard !routes.isEmpty else { return nil }
+ // Second: collect CLLocation waypoints from each route. HKWorkoutRouteQuery calls its
+ // handler repeatedly with batches of locations; `done: true` marks the end of one route.
+ var points: [RouteMath.LatLng] = []
+ for route in routes {
+ await withCheckedContinuation { (cont: CheckedContinuation) in
+ let query = HKWorkoutRouteQuery(route: route) { _, locations, done, _ in
+ for loc in locations ?? [] {
+ points.append(RouteMath.LatLng(loc.coordinate.latitude, loc.coordinate.longitude))
+ }
+ if done { cont.resume(returning: ()) }
+ }
+ store.execute(query)
+ }
+ }
+ return points.isEmpty ? nil : points
}
/// Source tag stamped on workouts imported from Apple Health. Matches the macOS importer's
diff --git a/StrandiOS/System/HomeScreenQuickActions.swift b/StrandiOS/System/HomeScreenQuickActions.swift
index b183d3369a..42c08a3fa7 100644
--- a/StrandiOS/System/HomeScreenQuickActions.swift
+++ b/StrandiOS/System/HomeScreenQuickActions.swift
@@ -1,4 +1,5 @@
#if os(iOS)
+import StrandAnalytics
import SwiftUI
import UIKit
@@ -82,6 +83,67 @@ final class HomeScreenQuickActionAppDelegate: NSObject, UIApplicationDelegate {
/// automatically places an observable scene delegate in the environment for the scene it manages.
@MainActor
final class HomeScreenQuickActionSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
+ // MARK: - Standard-HR persistence at the lifecycle edges (#1770)
+ //
+ // On the SCENE delegate, not the app delegate, and not on `StrandiOSApp.body`. Both placements are
+ // load-bearing and both were arrived at the hard way.
+ //
+ // Not the body: the same two edges were added there in #1767 and took the iOS build down. `body` is a
+ // SINGLE expression carrying ~28 chained modifiers; two more tipped it past the type-checker's budget,
+ // and the error was reported at a modifier a hundred lines from the change. The cost was the
+ // expression's length, not any leaf of it.
+ //
+ // Not the app delegate: this app is scene-based — it returns a UISceneConfiguration from
+ // `application(_:configurationForConnecting:options:)` — and UIKit does not call
+ // applicationDidEnterBackground or applicationWillTerminate on the app delegate of a scene-based app.
+ // Those methods would have compiled, shipped, and silently never run, which is a worse failure than a
+ // red build because nothing reports it.
+
+ /// Give buffered standard-HR rows a real persistence attempt as the scene leaves the foreground.
+ ///
+ /// iOS suspends a connected strap WITHOUT a disconnect edge, and the Collector's 30-sample /
+ /// 30-second cadence timer does not run while suspended — so a sub-cadence 0x2A37 batch sits in
+ /// memory and dies with the app if iOS terminates it before it resumes.
+ func sceneDidEnterBackground(_ scene: UIScene) {
+ // A bare Task is suspended along with the app, which would leave this doing nothing at the one
+ // moment it exists for. Ask UIKit for a window and end it on BOTH paths, so the assertion is
+ // always released rather than expiring.
+ let application = UIApplication.shared
+ var taskID: UIBackgroundTaskIdentifier = .invalid
+ taskID = application.beginBackgroundTask(withName: "standard-hr-lifecycle-flush") {
+ application.endBackgroundTask(taskID)
+ taskID = .invalid
+ }
+ // @MainActor explicitly: AppModel and BLEManager are both main-actor isolated, and a bare Task
+ // from a nonisolated delegate callback does not inherit that. Same idiom BLEManager uses for this
+ // exact call on the disconnect edge.
+ Task { @MainActor in
+ await Self.flushStandardHR(.background)
+ if taskID != .invalid {
+ application.endBackgroundTask(taskID)
+ taskID = .invalid
+ }
+ }
+ }
+
+ /// The final retry edge, for a scene the system is discarding. Best effort BY CONSTRUCTION: there is
+ /// no background window here and little time, so this is not a guarantee and must not be described as
+ /// one. Usually `sceneDidEnterBackground` has already drained the buffer and an empty Collector is a
+ /// cheap no-op.
+ func sceneDidDisconnect(_ scene: UIScene) {
+ Task { @MainActor in await Self.flushStandardHR(.termination) }
+ }
+
+ /// Reaches the live Collector through `AppModel.shared`, the seam App Intents already use. It is
+ /// `weak`, so a nil here means no model is alive and there is nothing buffered to lose.
+ @MainActor
+ private static func flushStandardHR(_ event: StandardHRLifecycleFlush.Event) async {
+ guard let ble = AppModel.shared?.ble else { return }
+ await StandardHRLifecycleFlush.run(event: event) { reason in
+ await ble.flushStandardHRForLifecycle(reason: reason)
+ }
+ }
+
@Published private(set) var pendingAction: HomeScreenQuickAction?
func scene(
diff --git a/StrandiOS/System/NOOPAppIntents.swift b/StrandiOS/System/NOOPAppIntents.swift
index ce0e847380..b14ec61f90 100644
--- a/StrandiOS/System/NOOPAppIntents.swift
+++ b/StrandiOS/System/NOOPAppIntents.swift
@@ -6,9 +6,12 @@ import AppIntents
/// into the running `AppModel` directly (BLE only lives in the foreground app), so they enqueue here
/// and the app drains the queue when it next becomes active.
enum PendingIntents {
- enum Action: String { case markMoment, buzz }
+ enum Action: String { case markMoment, buzz, askCoach }
private static let key = "noop.pendingIntents"
+ /// K9: the question text for a pending `.askCoach` action. Stored separately because the
+ /// action queue encodes as `[String]` and a question can contain colons.
+ private static let coachQuestionKey = "noop.pendingCoachQuestion"
private static var defaults: UserDefaults? { UserDefaults(suiteName: WidgetSnapshot.suiteName) }
/// Optional `at` is the invocation time, captured now and consumed on drain. Encoded into the
@@ -22,6 +25,23 @@ enum PendingIntents {
d.set(list, forKey: key)
}
+ /// K9: queue an "Ask Coach" action with the associated question text. The question is stored
+ /// in a dedicated key (one pending question at a time — the user rarely queues multiple Siri
+ /// questions before the app opens).
+ static func appendAskCoach(question: String, at date: Date? = nil) {
+ guard let d = defaults else { return }
+ d.set(question, forKey: coachQuestionKey)
+ append(.askCoach, at: date)
+ }
+
+ /// K9: read and clear the pending coach question. Returns nil when no question is queued.
+ static func consumeCoachQuestion() -> String? {
+ guard let d = defaults else { return nil }
+ let q = d.string(forKey: coachQuestionKey)
+ d.removeObject(forKey: coachQuestionKey)
+ return q
+ }
+
static func drain() -> [(action: Action, date: Date?)] {
guard let d = defaults else { return [] }
let raw = d.stringArray(forKey: key) ?? []
@@ -62,6 +82,24 @@ struct BuzzStrapIntent: AppIntent {
}
}
+/// K9: Ask the Coach a question via Siri. Queues the question and opens the app, which sends it
+/// to the configured provider and surfaces the response. The question is spoken or typed in Siri;
+/// the app handles the actual network call using the user's saved key.
+struct AskCoachIntent: AppIntent {
+ static var title: LocalizedStringResource = "Ask Coach"
+ static var description = IntentDescription("Ask your NOOP Coach a question about your recovery, sleep, or training.")
+ static var openAppWhenRun = true
+
+ /// The question to ask, populated by Siri from the user's spoken phrase.
+ @Parameter(title: "Question")
+ var question: String
+
+ func perform() async throws -> some IntentResult & ProvidesDialog {
+ PendingIntents.appendAskCoach(question: question, at: Date())
+ return .result(dialog: "Opening Coach with your question: \(question)")
+ }
+}
+
/// Surfaces NOOP's intents to Siri, Spotlight, and the Shortcuts gallery without any user setup.
struct NOOPShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
@@ -73,6 +111,16 @@ struct NOOPShortcuts: AppShortcutsProvider {
phrases: ["Buzz my \(.applicationName) strap"],
shortTitle: "Buzz Strap",
systemImageName: "waveform.path")
+ // K9: "Ask Coach" via Siri — opens Coach with the question and sends it. The question
+ // parameter is provided via the Shortcuts app or Siri prompts for it when the phrase fires.
+ AppShortcut(intent: AskCoachIntent(),
+ phrases: [
+ "Ask \(.applicationName) about my recovery",
+ "Ask \(.applicationName) how I'm doing",
+ "Ask \(.applicationName) Coach",
+ ],
+ shortTitle: "Ask Coach",
+ systemImageName: "sparkles")
}
}
#endif
diff --git a/StrandiOS/Widgets/WidgetPublish.swift b/StrandiOS/Widgets/WidgetPublish.swift
index 2f75feb1fb..f0ad2099d2 100644
--- a/StrandiOS/Widgets/WidgetPublish.swift
+++ b/StrandiOS/Widgets/WidgetPublish.swift
@@ -21,6 +21,7 @@ extension WidgetSnapshot {
/// the rollover yet always describes today.
@MainActor
static func publish(from model: AppModel) async {
+ await refreshWidgetPresence()
let days = model.repo.days
let now = Date()
// The recovery-derived anchor: today's row when it's scored, else the freshest STRICTLY-PRIOR
@@ -110,15 +111,59 @@ extension WidgetSnapshot {
private static func saveAndReloadIfChanged(_ snap: WidgetSnapshot, previous: WidgetSnapshot? = nil) {
let previous = previous ?? load()
if renderedContentChanged(from: previous, to: snap) {
- snap.save()
+ snap.save(previousSeries: previous?.hrSeries ?? [])
WidgetCenter.shared.reloadAllTimelines()
+ // Android skips the update entirely when no widget is placed; WidgetKit offers no
+ // synchronous way to know, so the reload still goes out and is instead recorded honestly.
+ // Counting it as a reload would make a widget-removed export read exactly like a
+ // widget-installed one, which is half the comparison the counters exist for.
+ if WidgetTelemetry.widgetsInstalled {
+ WidgetTelemetry.noteReloaded()
+ } else {
+ WidgetTelemetry.noteNoWidget()
+ }
+ } else if WidgetSnapshot.traceNeedsPoint(previous: previous, bpm: snap.bpm, now: snap.updated) {
+ // A steady heart changes nothing the header renders, so the branch above declines — but the
+ // TRACE still wants this minute's point, or it stops advancing at rest and prunes to empty
+ // (#1957). Persist without a reload: the point is for the next timeline WidgetKit builds,
+ // and spending a reload a minute is exactly what the dedup above exists to avoid.
+ snap.save(previousSeries: previous?.hrSeries ?? [])
+ WidgetTelemetry.noteDeclined()
} else if liveUpdateRequiresFullBuild(previous: previous, now: snap.updated) {
// The rollover's visible values can legitimately match yesterday's. Persist the fresh day
// stamp once without spending a redundant WidgetKit reload, so later live ticks stay fast.
- snap.save()
+ snap.save(previousSeries: previous?.hrSeries ?? [])
+ WidgetTelemetry.noteDeclined()
+ } else {
+ // Nothing at all to do. Counted rather than left as a silent fall-through: an outcome that
+ // records nothing is exactly how the Android counters came to report publishes that never
+ // went anywhere as if they had.
+ WidgetTelemetry.noteDeclined()
}
}
+ /// Ask WidgetKit whether any widget is actually installed, and remember the answer.
+ ///
+ /// Only on the full publish path: it is already `async`, and the once-a-minute live path has no
+ /// `await` to spend on an XPC round trip it does not need. The answer changes when a user adds or
+ /// removes a widget, which is exactly when the app is being foregrounded anyway, so a value from
+ /// the last full publish is fresh enough for a diagnostic.
+ ///
+ /// A failure leaves the previous answer in place rather than guessing, and "never asked" counts as
+ /// installed — over-reporting reloads is the safe direction for a figure meant to show a cost.
+ @MainActor
+ private static func refreshWidgetPresence() async {
+ let installed: Bool? = await withCheckedContinuation { continuation in
+ WidgetCenter.shared.getCurrentConfigurations { result in
+ switch result {
+ case .success(let widgets): continuation.resume(returning: !widgets.isEmpty)
+ case .failure: continuation.resume(returning: nil)
+ }
+ }
+ }
+ if let installed { WidgetTelemetry.noteWidgetsInstalled(installed) }
+ }
+
/// #114/#169: HR is the ONE high-frequency widget-publish trigger — `model.bpm` moves every few
/// seconds during activity, unlike battery (~8 min) or connection flips (rare). Left ungated, the
/// `model.$bpm` hook rewrote the shared snapshot + called `reloadAllTimelines()` on every tick (and,
@@ -133,8 +178,12 @@ extension WidgetSnapshot {
/// True (and stamps `now`) when at least `interval` has elapsed since the last HR-driven publish;
/// false to skip this HR change. The first call always admits (`.distantPast`).
static func admit(now: Date = Date()) -> Bool {
- guard now.timeIntervalSince(lastPublishedAt) >= interval else { return false }
+ guard now.timeIntervalSince(lastPublishedAt) >= interval else {
+ WidgetTelemetry.noteGated(now: now)
+ return false
+ }
lastPublishedAt = now
+ WidgetTelemetry.noteAdmitted(now: now)
return true
}
}
diff --git a/StrandiOSShared/HrTrace.swift b/StrandiOSShared/HrTrace.swift
new file mode 100644
index 0000000000..b53b929538
--- /dev/null
+++ b/StrandiOSShared/HrTrace.swift
@@ -0,0 +1,169 @@
+import Foundation
+
+/// One point on the widget's heart-rate trace: when it was taken, and the bpm.
+///
+/// `Sendable` because a SwiftUI `Shape` holding these is itself Sendable, and the compiler warns that
+/// carrying a non-Sendable stored property there is an error in a future language mode. Two immutable
+/// value fields, so it is Sendable by inspection rather than by assertion.
+public struct HrPoint: Codable, Equatable, Sendable {
+ public let ts: Int64
+ public let bpm: Int
+ public init(ts: Int64, bpm: Int) {
+ self.ts = ts
+ self.bpm = bpm
+ }
+}
+
+/// The pure half of the heart-rate widget: what the trace CONTAINS and where its ink goes.
+///
+/// Swift twin of the Kotlin `HrTrace`. The retention rule, the tick choices and the normalisation are
+/// the same decisions on both platforms and must not be made twice — a trace that kept a different
+/// window, or picked different labels, would be a different reading of the same heart.
+///
+/// What deliberately does NOT port is the bitmap sizing: `fitBox`, the byte budget and the 565 pixel
+/// depth exist because Glance compiles to RemoteViews, cannot draw, and carries its payload over a
+/// Binder transaction with a hard ceiling. WidgetKit is SwiftUI and strokes the trace as a vector, so
+/// there is no bitmap to budget for and nothing here needs a pixel format.
+///
+/// `encode`/`decode` do not port either. Android packs the series into a SharedPreferences string
+/// because that is what it has; the snapshot here is already Codable and carries `[HrPoint]` directly,
+/// so a hand-rolled format would be a second encoding to keep correct for no gain.
+public enum HrTrace {
+
+ /// How much history the trace shows. Three hours matches what fits legibly at widget width.
+ public static let windowSec: Int64 = 3 * 60 * 60
+
+ /// One point per minute. The strap streams ~1/s; keeping every sample would put 10 800 points
+ /// behind a trace a few hundred points wide, which costs space and buys nothing the eye resolves.
+ public static let bucketSec: Int64 = 60
+
+ /// Hard cap, so a clock jump backwards cannot grow the series without bound.
+ public static let maxPoints = 200
+
+ /// Fold a fresh reading into the series: one point per minute bucket, newest wins within a bucket.
+ ///
+ /// Newest-wins rather than an average because the widget's headline number is the LATEST bpm, and a
+ /// trace whose last point disagreed with the number printed above it would read as a bug.
+ public static func append(_ series: [HrPoint], ts: Int64, bpm: Int, nowSec: Int64? = nil) -> [HrPoint] {
+ let now = nowSec ?? ts
+ guard bpm > 0 else { return prune(series, nowSec: now) }
+ let bucket = ts / bucketSec * bucketSec
+ var out = series.filter { $0.ts != bucket }
+ out.append(HrPoint(ts: bucket, bpm: bpm))
+ // No sort here: `prune` sorts on the way out and has to anyway, since it is also the entry point
+ // for a decoded series. Appending at the end is already in order for anything but a backwards
+ // clock, and prune's sort is what makes even that correct.
+ return prune(out, nowSec: now)
+ }
+
+ /// Drop anything older than the window, then anything beyond the cap (oldest first).
+ public static func prune(_ series: [HrPoint], nowSec: Int64) -> [HrPoint] {
+ let floor = nowSec - windowSec
+ let kept = series.filter { $0.ts >= floor }.sorted { $0.ts < $1.ts }
+ guard kept.count > maxPoints else { return kept }
+ return Array(kept.suffix(maxPoints))
+ }
+
+ /// The three numbers the header shows. Nil when there is nothing to describe.
+ public struct Stats: Equatable {
+ public let min: Int
+ public let max: Int
+ public let latest: Int
+ }
+
+ public static func stats(_ series: [HrPoint]) -> Stats? {
+ guard let first = series.first, let last = series.last else { return nil }
+ var lo = first.bpm
+ var hi = first.bpm
+ for p in series {
+ if p.bpm < lo { lo = p.bpm }
+ if p.bpm > hi { hi = p.bpm }
+ }
+ return Stats(min: lo, max: hi, latest: last.bpm)
+ }
+
+ /// A point in the trace's drawing box, origin top-left.
+ public struct Pt: Equatable {
+ public let x: CGFloat
+ public let y: CGFloat
+ }
+
+ /// The trace as coordinates inside a `width` x `height` box.
+ ///
+ /// Two degenerate shapes decide most of this, and both are ordinary rather than exotic — a widget
+ /// placed mid-afternoon has one point, and a resting arm holds one bpm for minutes at a time:
+ ///
+ /// - ONE point, or every point at the same instant: there is no time axis to spread across, so it
+ /// sits at the left edge rather than at x = NaN.
+ /// - every bpm equal: there is no range to scale into, so the line runs along the VERTICAL MIDDLE.
+ /// Pinning it to the top or bottom would read as a maxed-out or flatlined heart.
+ ///
+ /// Y is flipped on the way out: bpm rises upward, screen coordinates rise downward.
+ public static func points(_ series: [HrPoint], width: CGFloat, height: CGFloat) -> [Pt] {
+ guard !series.isEmpty, width > 0, height > 0,
+ let t0 = series.first?.ts, let t1 = series.last?.ts else { return [] }
+ let span = CGFloat(t1 - t0)
+ var lo = series[0].bpm
+ var hi = series[0].bpm
+ for p in series {
+ if p.bpm < lo { lo = p.bpm }
+ if p.bpm > hi { hi = p.bpm }
+ }
+ let range = CGFloat(hi - lo)
+ return series.map { p in
+ let x = span <= 0 ? 0 : CGFloat(p.ts - t0) / span * width
+ let y = range <= 0 ? height / 2 : height - CGFloat(p.bpm - lo) / range * height
+ return Pt(x: x, y: y)
+ }
+ }
+
+ /// The three bpm labels down the right edge: max, midpoint, min, stacked as they appear.
+ ///
+ /// A flat series returns the same number three times rather than an invented spread — the honest
+ /// reading of a steady heart is one number. The VIEW declines to draw a scale in that case; the
+ /// rule stays here so both platforms agree on what the labels would be.
+ public static func bpmTicks(_ stats: Stats) -> [Int] {
+ [stats.max, (stats.min + stats.max + 1) / 2, stats.min]
+ }
+
+ /// The three timestamps along the bottom: first, middle, last of the DATA, not of the window.
+ ///
+ /// Anchored to the data because a widget holding forty minutes of history would otherwise label its
+ /// axis with two hours that were never sampled. Fewer than three distinct instants returns what
+ /// there is, so the view draws one label rather than three copies of it.
+ public static func timeTicks(_ series: [HrPoint]) -> [Int64] {
+ guard let first = series.first?.ts, let last = series.last?.ts else { return [] }
+ if first == last { return [first] }
+ let mid = first + (last - first) / 2
+ if mid == first || mid == last { return [first, last] }
+ return [first, mid, last]
+ }
+}
+
+/// What heart rate the widget should SHOW, given how long ago it was actually measured.
+///
+/// Twin of the Kotlin `HrDisplay`, and the reason it exists is the same on both platforms: a snapshot
+/// carries the last reading indefinitely, so without an age rule a widget renders an hours-old number as
+/// though it were current.
+///
+/// Anchored to the newest TRACE POINT rather than to the snapshot's `updated`, which is the closer twin
+/// of Android reading its own `hrAt`. A publish happens for battery or score changes too, so `updated`
+/// refreshes without a new reading and would make a stale heart look fresh.
+public enum HrDisplay {
+ /// Past this without a newer reading the number is DIMMED: still shown, no longer claimed as live.
+ public static let liveWindow: TimeInterval = 2 * 60
+
+ /// Past this it is dropped entirely — too old to stand for the wearer at all.
+ public static let staleCap: TimeInterval = 15 * 60
+
+ /// - Returns: the bpm to show (nil to show nothing) and whether it is a carried-over reading.
+ public static func resolve(bpm: Int?, newestPointTs: Int64?, now: Date) -> (bpm: Int?, stale: Bool) {
+ guard let bpm, bpm > 0 else { return (nil, false) }
+ // No trace yet is the FIRST reading, not an old one: a widget added this minute has a bpm and an
+ // empty series, and dropping the number there would blank a widget that just started working.
+ guard let newestPointTs else { return (bpm, false) }
+ let age = now.timeIntervalSince1970 - TimeInterval(newestPointTs)
+ if age > staleCap { return (nil, false) }
+ return (bpm, age > liveWindow)
+ }
+}
diff --git a/StrandiOSShared/WidgetSnapshot.swift b/StrandiOSShared/WidgetSnapshot.swift
index fb7e5c6f5e..34075848d0 100644
--- a/StrandiOSShared/WidgetSnapshot.swift
+++ b/StrandiOSShared/WidgetSnapshot.swift
@@ -21,10 +21,17 @@ public struct WidgetSnapshot: Codable, Equatable {
public var effortDisplay: String?
/// True when `effortDisplay` is on WHOOP's 0–21 axis; false/nil means 0–100. Accessibility only.
public var effortWhoop: Bool?
+ /// The last `HrTrace.windowSec` of heart rate, one point per minute, for the trace widget (#1957).
+ ///
+ /// Folded in by `save()` rather than by the callers that build a snapshot, which is the twin of the
+ /// Android store owning it: nothing that publishes had to learn the retention rule. Optional so a
+ /// snapshot written by an older build still decodes.
+ public var hrSeries: [HrPoint]?
public init(recovery: Int?, bpm: Int?, batteryPct: Int?, bonded: Bool, updated: Date,
effort: Int? = nil, rest: Int? = nil, hrv: Int? = nil, restingHr: Int? = nil,
- effortDisplay: String? = nil, effortWhoop: Bool? = nil) {
+ effortDisplay: String? = nil, effortWhoop: Bool? = nil,
+ hrSeries: [HrPoint]? = nil) {
self.recovery = recovery
self.bpm = bpm
self.batteryPct = batteryPct
@@ -36,6 +43,7 @@ public struct WidgetSnapshot: Codable, Equatable {
self.restingHr = restingHr
self.effortDisplay = effortDisplay
self.effortWhoop = effortWhoop
+ self.hrSeries = hrSeries
}
/// App Group suite the app and widget both use. Injected from the `APP_GROUP_ID` build setting
@@ -113,13 +121,47 @@ public struct WidgetSnapshot: Codable, Equatable {
return snap
}
- /// Persist this snapshot into the shared suite.
+ /// Persist this snapshot into the shared suite, folding the live bpm into the trace on the way.
+ ///
+ /// The fold happens HERE, not in the callers that build a snapshot, so nothing that publishes has to
+ /// know the retention rule — the twin of the Android store owning it. A snapshot with no bpm leaves
+ /// the stored trace alone rather than truncating it, so a quiet strap does not erase the history the
+ /// widget is drawing.
public func save() {
- guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName),
- let data = try? JSONEncoder().encode(self) else { return }
+ save(previousSeries: WidgetSnapshot.load()?.hrSeries ?? [])
+ }
+
+ /// As `save()`, for a caller that already holds the stored snapshot.
+ ///
+ /// The publish path loads `previous` to decide whether anything changed, and `save()` was then
+ /// decoding the same App Group blob a second time just to reach the trace. Handing the series in
+ /// costs the caller nothing and removes a full JSON decode from every publish.
+ public func save(previousSeries: [HrPoint]) {
+ guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName) else { return }
+ var toStore = self
+ let previous = previousSeries
+ let nowSec = Int64(updated.timeIntervalSince1970)
+ toStore.hrSeries = bpm.map { HrTrace.append(previous, ts: nowSec, bpm: $0, nowSec: nowSec) }
+ ?? HrTrace.prune(previous, nowSec: nowSec)
+ guard let data = try? JSONEncoder().encode(toStore) else { return }
defaults.set(data, forKey: WidgetSnapshot.storageKey)
}
+ /// Does the TRACE need a point, even though nothing the header renders has changed?
+ ///
+ /// `renderedContentChanged` compares bpm, not history, so a steady heart — the ordinary case at rest
+ /// — produced no publish and therefore no new trace point. The trace would stop advancing while the
+ /// strap streamed happily, and pruning would eventually empty it. Android does not have this problem
+ /// because its PushGate re-admits an unchanged key once a minute; this is that rule.
+ ///
+ /// Keyed on the BUCKET rather than elapsed seconds, so it asks for a write exactly when
+ /// `HrTrace.append` would actually record one, and never more often.
+ static func traceNeedsPoint(previous: WidgetSnapshot?, bpm: Int?, now: Date) -> Bool {
+ guard let bpm, bpm > 0 else { return false }
+ guard let last = previous?.hrSeries?.last else { return true }
+ return Int64(now.timeIntervalSince1970) / HrTrace.bucketSec > last.ts / HrTrace.bucketSec
+ }
+
/// Whether publishing `next` would change anything the widget actually renders. `updated` is
/// deliberately excluded: no widget family displays it, and treating a fresh timestamp as content
/// would defeat deduplication because every otherwise-identical build creates a new date.
diff --git a/StrandiOSShared/WidgetTelemetry.swift b/StrandiOSShared/WidgetTelemetry.swift
new file mode 100644
index 0000000000..e203764e75
--- /dev/null
+++ b/StrandiOSShared/WidgetTelemetry.swift
@@ -0,0 +1,179 @@
+import Foundation
+
+/// Counters for what the home-screen widgets cost, so "the widget drains my battery" is decidable
+/// from an export instead of being argued from the code.
+///
+/// Twin of Android's `WidgetTelemetry`, and deliberately only HALF of it. The push accounting maps
+/// one to one — `HRPublishThrottle` gates, `saveAndReloadIfChanged` either reloads or declines — so
+/// the same four outcomes exist here and are worth the same counters. The bitmap accounting does not
+/// map at all: this widget draws a SwiftUI `Path` inside the extension, with no bitmap and no Binder
+/// transaction, so draws, bytes and MB/h would be measuring something that does not happen.
+///
+/// One cost is iOS-ONLY and is the reason this is worth having rather than merely symmetrical.
+/// WidgetKit budgets timeline reloads, and spending the budget does not cost battery — it makes the
+/// widget go STALE, which is a correctness failure the user sees. A reloads-per-hour figure is the
+/// only way to know how close the app runs to that ceiling, and nothing has ever measured it.
+///
+/// Process-lifetime, reset when the process dies, reported as a rate over the steady window so a
+/// short session and a long one are comparable. Not persisted: writing to disk on every publish to
+/// measure the cost of publishing would be its own answer to the question.
+public enum WidgetTelemetry {
+
+ /// How long after the first publish to ignore before the rates start counting.
+ ///
+ /// The snapshot's fields populate one after another at launch and each is a content change that
+ /// publishes immediately, so a launch produces a burst that the once-a-minute HR throttle has
+ /// nothing to do with. An Android capture reported six pushes in a hundred seconds as 215/h
+ /// against a steady state of about sixty; the same shape applies here.
+ private static let warmup: TimeInterval = 60
+
+ private static let lock = NSLock()
+ private static var startedAt: Date?
+ private static var steadyStartedAt: Date?
+ private static var offered = 0
+ private static var gated = 0
+ private static var admitted = 0
+ private static var reloaded = 0
+ private static var declined = 0
+ private static var steadyReloads = 0
+ private static var noWidget = 0
+ private static var steadyNoWidget = 0
+ private static var installedKnown: Bool?
+
+ /// A publish that `HRPublishThrottle` let through.
+ public static func noteAdmitted(now: Date = Date()) {
+ lock.lock(); defer { lock.unlock() }
+ if startedAt == nil { startedAt = now }
+ offered += 1
+ admitted += 1
+ if let started = startedAt, now.timeIntervalSince(started) >= warmup, steadyStartedAt == nil {
+ steadyStartedAt = now
+ }
+ }
+
+ /// A publish the throttle dropped. At live-HR cadence this should dwarf the admitted count.
+ public static func noteGated(now: Date = Date()) {
+ lock.lock(); defer { lock.unlock() }
+ if startedAt == nil { startedAt = now }
+ offered += 1
+ gated += 1
+ }
+
+ /// A publish that actually asked WidgetKit for a new timeline. THE figure that matters here: this
+ /// is what spends the reload budget.
+ public static func noteReloaded(now: Date = Date()) {
+ lock.lock(); defer { lock.unlock() }
+ reloaded += 1
+ if steadyStartedAt != nil { steadyReloads += 1 }
+ }
+
+ /// A publish that persisted but declined the reload, because nothing a widget renders had changed
+ /// (or only the trace advanced, which the next timeline picks up anyway).
+ public static func noteDeclined() {
+ lock.lock(); defer { lock.unlock() }
+ declined += 1
+ }
+
+ /// A reload asked for with no widget installed to receive it.
+ ///
+ /// Android learned this the hard way: an export taken with the widget REMOVED is half of the
+ /// comparison that answers whether the widget costs anything, and counting these as reloads made
+ /// both halves read alike. iOS never checks whether a widget is placed before calling
+ /// `reloadAllTimelines`, so without this the same figure would be just as blind here.
+ public static func noteNoWidget() {
+ lock.lock(); defer { lock.unlock() }
+ noWidget += 1
+ if steadyStartedAt != nil { steadyNoWidget += 1 }
+ }
+
+ /// Record what WidgetKit says about installed widgets. Refreshed on the async publish paths, which
+ /// is the only place an `await` is available; the synchronous live path reads the last answer.
+ ///
+ /// Nil means never asked, and nil counts as INSTALLED at the call site. Unknown must not be able to
+ /// invent a saving that was never made — over-reporting reloads is the safe direction for a figure
+ /// whose purpose is to show a cost.
+ public static func noteWidgetsInstalled(_ installed: Bool) {
+ lock.lock(); defer { lock.unlock() }
+ installedKnown = installed
+ }
+
+ /// The last known answer, defaulting to true. See [noteWidgetsInstalled].
+ public static var widgetsInstalled: Bool {
+ lock.lock(); defer { lock.unlock() }
+ return installedKnown ?? true
+ }
+
+ public static func snapshot(now: Date = Date()) -> Snapshot {
+ lock.lock(); defer { lock.unlock() }
+ return Snapshot(
+ uptime: startedAt.map { now.timeIntervalSince($0) } ?? 0,
+ steady: steadyStartedAt.map { now.timeIntervalSince($0) } ?? 0,
+ offered: offered,
+ gated: gated,
+ admitted: admitted,
+ reloaded: reloaded,
+ declined: declined,
+ noWidget: noWidget,
+ steadyReloads: steadyReloads,
+ steadyNoWidget: steadyNoWidget
+ )
+ }
+
+ public static func resetForTest() {
+ lock.lock(); defer { lock.unlock() }
+ startedAt = nil; steadyStartedAt = nil
+ offered = 0; gated = 0; admitted = 0; reloaded = 0; declined = 0; steadyReloads = 0
+ noWidget = 0; steadyNoWidget = 0; installedKnown = nil
+ }
+
+ public struct Snapshot: Sendable, Equatable {
+ public let uptime: TimeInterval
+ public let steady: TimeInterval
+ public let offered: Int
+ public let gated: Int
+ public let admitted: Int
+ public let reloaded: Int
+ public let declined: Int
+ public let noWidget: Int
+ public let steadyReloads: Int
+ public let steadyNoWidget: Int
+
+ /// A rate is only quoted once the steady window is long enough to mean something. Five minutes
+ /// of ordinary running is a handful of one-a-minute publishes; less is arithmetic.
+ private var steadyEnough: Bool { steady >= 5 * 60 }
+
+ /// Timeline reloads per hour, over the steady window.
+ ///
+ /// Reloads and not publishes, because a publish that declined the reload cost nothing and
+ /// spent none of the budget. Counting those would make this figure unable to fall when the
+ /// dedup in `saveAndReloadIfChanged` did its job, which is the one thing it is for.
+ ///
+ /// A reload asked for with no widget installed is not counted here either — it is recorded as
+ /// [noWidget] instead of as a reload, so this figure never claims a cost that had nowhere to
+ /// land.
+ ///
+ /// Elapsed WALL-CLOCK time is the denominator, not time spent streaming: the ceiling this runs
+ /// against is expressed per hour of real time, as is a battery question.
+ public var reloadsPerHour: Double? {
+ guard steadyEnough, steady > 0 else { return nil }
+ return Double(steadyReloads) * 3600 / steady
+ }
+
+ /// One line for the diagnostics header, matching the Android wording so two exports from the
+ /// same household read the same way.
+ public func render() -> String {
+ guard offered > 0 else { return "Widgets: no publishes this app session" }
+ var parts: [String] = ["\(reloaded) reloaded / \(admitted) admitted / \(offered) offered"]
+ if let rate = reloadsPerHour {
+ parts.append(String(format: "%.1f/h", rate))
+ }
+ if declined > 0 { parts.append("\(declined) unchanged") }
+ if noWidget > 0 { parts.append("\(noWidget) with no widget installed") }
+ let mins = Int(uptime / 60)
+ let span = steadyEnough
+ ? "over \(mins)m"
+ : "over \(mins)m, steady \(Int(steady / 60))m of 5m"
+ return "Widgets: " + parts.joined(separator: " · ") + " (\(span))"
+ }
+ }
+}
diff --git a/StrandiOSWidgets/CoachBriefWidget.swift b/StrandiOSWidgets/CoachBriefWidget.swift
new file mode 100644
index 0000000000..a3fa59b298
--- /dev/null
+++ b/StrandiOSWidgets/CoachBriefWidget.swift
@@ -0,0 +1,183 @@
+import WidgetKit
+import SwiftUI
+import StrandDesign
+
+/// K10: A Lock Screen / Home Screen widget showing the stored Coach morning brief.
+///
+/// Design contract (see PRD-K10 + D8):
+/// - The widget reads **stored** brief text from the App Group — it NEVER calls the network.
+/// The brief is generated on a schedule by `CoachBriefScheduler` (K5) and mirrored into the
+/// App Group via `publishToWidget`. The widget just displays whatever text is there.
+/// - Tap → opens the Coach tab (via the app's URL scheme / deeplink).
+/// - Supported families: `accessoryRectangular` (Lock Screen), `systemSmall` (Home Screen).
+/// The Lock Screen accessory shows the first line; the Home Screen widget shows more.
+struct CoachBriefEntry: TimelineEntry {
+ let date: Date
+ let briefText: String?
+ let briefDate: Date?
+}
+
+struct CoachBriefProvider: TimelineProvider {
+ /// App Group keys — must match `CoachBriefScheduler.K.widgetBriefKey` / `.widgetBriefDateKey`.
+ private static let briefKey = "coachBrief.widgetText"
+ private static let briefDateKey = "coachBrief.widgetDate"
+
+ func placeholder(in context: Context) -> CoachBriefEntry {
+ CoachBriefEntry(
+ date: Date(),
+ briefText: "Recovery is strong today — consider a higher-intensity session this afternoon.",
+ briefDate: Date()
+ )
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (CoachBriefEntry) -> Void) {
+ let entry = loadEntry()
+ completion(entry)
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let entry = loadEntry()
+ // Refresh every 30 minutes — the app pushes a reload via WidgetCenter when a new brief is
+ // published, so this is just a safety net for when the app isn't running.
+ let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())
+ ?? Date().addingTimeInterval(1800)
+ completion(Timeline(entries: [entry], policy: .after(next)))
+ }
+
+ private func loadEntry() -> CoachBriefEntry {
+ let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName)
+ let text = defaults?.string(forKey: CoachBriefProvider.briefKey)
+ let date = defaults?.object(forKey: CoachBriefProvider.briefDateKey) as? Date
+ return CoachBriefEntry(date: Date(), briefText: text, briefDate: date)
+ }
+}
+
+struct CoachBriefWidgetView: View {
+ @Environment(\.widgetFamily) private var family
+ let entry: CoachBriefEntry
+
+ var body: some View {
+ switch family {
+ case .accessoryRectangular:
+ rectangular
+ case .accessoryInline:
+ inline
+ default:
+ small
+ }
+ }
+
+ // MARK: - Lock Screen: accessoryRectangular
+
+ private var rectangular: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 4) {
+ Image(systemName: "sparkles")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(StrandPalette.accent)
+ Text("Coach")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundStyle(StrandPalette.textSecondary)
+ Spacer(minLength: 0)
+ if let date = entry.briefDate {
+ Text(date, style: .time)
+ .font(.caption2)
+ .foregroundStyle(StrandPalette.textTertiary)
+ }
+ }
+ Text(briefDisplay)
+ .font(.system(size: 11))
+ .foregroundStyle(StrandPalette.textPrimary)
+ .lineLimit(3)
+ .minimumScaleFactor(0.8)
+ }
+ }
+
+ // MARK: - Lock Screen: accessoryInline
+
+ private var inline: some View {
+ Text(briefOneLine)
+ }
+
+ // MARK: - Home Screen: systemSmall
+
+ private var small: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 6) {
+ Image(systemName: "sparkles")
+ .font(.system(size: 13, weight: .bold))
+ .foregroundStyle(StrandPalette.accent)
+ Text("Coach Brief")
+ .font(.system(size: 13, weight: .bold))
+ .foregroundStyle(StrandPalette.textSecondary)
+ Spacer()
+ }
+ if entry.briefText == nil {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("No brief yet")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundStyle(StrandPalette.textTertiary)
+ Text("Enable Morning Brief in Coach settings to see today's readiness here.")
+ .font(.system(size: 11))
+ .foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ } else {
+ Text(briefDisplay)
+ .font(.system(size: 12))
+ .foregroundStyle(StrandPalette.textPrimary)
+ .lineLimit(5)
+ .minimumScaleFactor(0.8)
+ }
+ Spacer(minLength: 0)
+ if let date = entry.briefDate {
+ Text(date, format: .dateTime.hour().minute())
+ .font(.caption2)
+ .foregroundStyle(StrandPalette.textTertiary)
+ }
+ }
+ .padding(12)
+ }
+
+ // MARK: - Text helpers
+
+ /// The full brief text for the widget body, or a placeholder when there's no brief.
+ private var briefDisplay: String {
+ entry.briefText ?? "No brief available."
+ }
+
+ /// One-line summary for the inline accessory (capped at ~100 chars).
+ private var briefOneLine: String {
+ guard let text = entry.briefText else { return "Coach: no brief yet" }
+ let firstLine = text.split(separator: "\n", omittingEmptySubsequences: true)
+ .first.map(String.init) ?? text
+ let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.count > 100 else { return "Coach: \(trimmed)" }
+ let cut = trimmed.index(trimmed.startIndex, offsetBy: 100)
+ return "Coach: \(trimmed[.. HeartRateEntry {
+ HeartRateEntry(date: Date(), snap: nil)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (HeartRateEntry) -> Void) {
+ completion(HeartRateEntry(date: Date(), snap: WidgetSnapshot.load()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let entry = HeartRateEntry(date: Date(), snap: WidgetSnapshot.load())
+ // The app reloads timelines when the heart rate moves, so this is only the safety net for when
+ // it is not running. Fifteen minutes rather than the trace's one-minute bucket: a widget cannot
+ // outrun its publisher, and asking more often spends budget WidgetKit would decline anyway.
+ let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date())
+ ?? Date().addingTimeInterval(900)
+ completion(Timeline(entries: [entry], policy: .after(next)))
+ }
+}
+
+/// The trace itself: a stroked path with a fill beneath it.
+///
+/// Takes its size from the geometry rather than a guess, which is the whole advantage over the Android
+/// twin — there is no bitmap, so nothing has to predict the box or survive being stretched into it.
+private struct HrTraceShape: Shape {
+ let series: [HrPoint]
+ /// When true, close the path down to the baseline for the gradient fill.
+ let filled: Bool
+
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ let pts = HrTrace.points(series, width: rect.width, height: rect.height)
+ guard let first = pts.first else { return path }
+ if pts.count == 1 {
+ // One reading is not a line. A dot says "one reading"; an empty box says "no data", and
+ // those are different things.
+ guard !filled else { return path }
+ let r: CGFloat = 2.5
+ path.addEllipse(in: CGRect(x: max(first.x, r) - r, y: first.y - r, width: r * 2, height: r * 2))
+ return path
+ }
+ path.move(to: CGPoint(x: first.x, y: first.y))
+ for p in pts.dropFirst() { path.addLine(to: CGPoint(x: p.x, y: p.y)) }
+ if filled {
+ path.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: rect.maxY))
+ path.addLine(to: CGPoint(x: first.x, y: rect.maxY))
+ path.closeSubpath()
+ }
+ return path
+ }
+}
+
+struct HeartRateWidgetView: View {
+ let entry: HeartRateEntry
+
+ /// Pruned on the way OUT as well as on the way in, matching the Kotlin twin. A widget rendered
+ /// hours after the last publish would otherwise draw a trace whose newest point is long stale, under
+ /// a time axis implying it is current — and WidgetKit renders an entry at ITS date, which is why the
+ /// window is measured from `entry.date` rather than from `Date()`.
+ private var series: [HrPoint] {
+ HrTrace.prune(entry.snap?.hrSeries ?? [], nowSec: Int64(entry.date.timeIntervalSince1970))
+ }
+ private var stats: HrTrace.Stats? { HrTrace.stats(series) }
+ /// Age-checked, so an hours-old reading is not printed as current. Without this the prune above made
+ /// the card incoherent: the trace emptied while the headline kept its confident number.
+ private var shown: (bpm: Int?, stale: Bool) {
+ HrDisplay.resolve(bpm: entry.snap?.bpm, newestPointTs: series.last?.ts, now: entry.date)
+ }
+ /// The palette's HR zone-5 token, which resolves to exactly the hexes the Android widget carries as
+ /// a local mirror (#C84E1E / #E0662F) — so the two widgets are the same colour rather than two
+ /// approximations of one. Named rather than hardcoded here because, unlike Glance, this target can
+ /// read the design package; and being a token it follows the Classic theme where a hex could not.
+ private var accent: Color { StrandPalette.zone5 }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ Image(systemName: "heart.fill")
+ .font(.system(size: 12))
+ .foregroundStyle(accent)
+ Text("Heart rate")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(StrandPalette.textPrimary)
+ }
+
+ HStack(alignment: .lastTextBaseline, spacing: 4) {
+ Text(shown.bpm.map(String.init) ?? "—")
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ .foregroundStyle(shown.stale ? StrandPalette.textSecondary : StrandPalette.textPrimary)
+ if shown.bpm != nil {
+ Text("bpm")
+ .font(.system(size: 12))
+ .foregroundStyle(StrandPalette.textSecondary)
+ }
+ if let stats {
+ Text("Min \(stats.min) • Max \(stats.max)")
+ .font(.system(size: 11))
+ .foregroundStyle(StrandPalette.textPrimary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(accent.opacity(0.18), in: Capsule())
+ .padding(.leading, 6)
+ }
+ }
+
+ if !series.isEmpty {
+ HStack(alignment: .top, spacing: 6) {
+ HrTraceChart(series: series, accent: accent)
+ // A scale of one repeated number says nothing the headline has not, so it waits for
+ // a range — the same rule the Android twin follows.
+ if let stats, stats.max > stats.min {
+ // Ticks computed ONCE, and the gap keyed on the INDEX. Comparing the value to
+ // `.last` happened to work only because a scale is drawn solely when there is a
+ // range; with two equal ticks it would have dropped a spacer and skewed the scale.
+ let ticks = HrTrace.bpmTicks(stats)
+ VStack(alignment: .trailing) {
+ ForEach(Array(ticks.enumerated()), id: \.offset) { index, tick in
+ Text("\(tick)")
+ .font(.system(size: 10))
+ .foregroundStyle(StrandPalette.textSecondary)
+ if index < ticks.count - 1 { Spacer(minLength: 0) }
+ }
+ }
+ }
+ }
+ HrTimeAxis(series: series)
+ }
+
+ Spacer(minLength: 0)
+ if let updated = entry.snap?.updated, updated != .distantPast {
+ HStack {
+ Spacer()
+ Text("Updated \(updated, format: .dateTime.hour().minute())")
+ .font(.system(size: 10))
+ .foregroundStyle(StrandPalette.textSecondary)
+ Spacer()
+ }
+ }
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(accessibilityText)
+ }
+
+ /// One spoken sentence rather than a run of loose numbers. The Android twin had to settle for
+ /// per-label descriptions because Glance cannot mark a Text decorative; SwiftUI can combine the
+ /// whole card, so it does. Staleness is not encoded in colour here at all, so nothing needs saying
+ /// about it.
+ private var accessibilityText: String {
+ // String(localized:) rather than bare literals. The audit DOES scan `.accessibilityLabel(`, but
+ // it matches a literal sitting immediately after the paren — and this is a computed property, so
+ // extracting the sentence here hid it from the check. Hardcoded English would have shipped to
+ // every locale with the gate green, which is the same trap the Kotlin twin records for copy
+ // written inside a semantics {} lambda.
+ guard let bpm = shown.bpm else { return String(localized: "Heart rate, no reading") }
+ guard let stats else { return String(localized: "Heart rate \(bpm) bpm") }
+ return String(localized: "Heart rate \(bpm) bpm, minimum \(stats.min), maximum \(stats.max)")
+ }
+}
+
+private struct HrTraceChart: View {
+ let series: [HrPoint]
+ let accent: Color
+
+ var body: some View {
+ ZStack {
+ HrTraceShape(series: series, filled: true)
+ .fill(LinearGradient(
+ colors: [accent.opacity(0.35), accent.opacity(0)],
+ startPoint: .top, endPoint: .bottom,
+ ))
+ HrTraceShape(series: series, filled: false)
+ .stroke(accent, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+private struct HrTimeAxis: View {
+ let series: [HrPoint]
+
+ var body: some View {
+ let ticks = HrTrace.timeTicks(series)
+ // One instant pinned to the left edge reads as a stray rather than an axis, so it waits for a
+ // span to label — matching the twin.
+ if ticks.count >= 2 {
+ HStack {
+ ForEach(Array(ticks.enumerated()), id: \.offset) { i, ts in
+ Text(Date(timeIntervalSince1970: TimeInterval(ts)),
+ format: .dateTime.hour().minute())
+ .font(.system(size: 9))
+ .foregroundStyle(StrandPalette.textSecondary)
+ if i < ticks.count - 1 { Spacer(minLength: 0) }
+ }
+ }
+ }
+ }
+}
+
+struct HeartRateWidget: Widget {
+ static let kind = "HeartRateWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: Self.kind, provider: HeartRateProvider()) { entry in
+ if #available(iOS 17.0, *) {
+ HeartRateWidgetView(entry: entry)
+ .containerBackground(StrandPalette.surfaceBase, for: .widget)
+ } else {
+ HeartRateWidgetView(entry: entry)
+ .padding()
+ .background(StrandPalette.surfaceBase)
+ }
+ }
+ .configurationDisplayName("Heart Rate")
+ .description("Live heart rate with the last three hours as a trace.")
+ .supportedFamilies([.systemMedium])
+ }
+}
diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift
index 5e55dc7ebf..d149ba447f 100644
--- a/StrandiOSWidgets/NOOPWidget.swift
+++ b/StrandiOSWidgets/NOOPWidget.swift
@@ -29,10 +29,14 @@ struct NOOPProvider: TimelineProvider {
}
/// The glanceable widget — the iOS analogue of the macOS menu-bar extra.
-/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings; Lock Screen
-/// accessories stay compact single-line / gauge layouts.
+/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings. Lock Screen
+/// accessories are compact: a single line, a gauge, or the rectangular glyph-over-value trio.
struct NOOPWidgetView: View {
@Environment(\.widgetFamily) private var family
+ /// `.fullColor` on the home screen and in the gallery; `.vibrant` or `.accented` on the lock screen,
+ /// where the system desaturates any colour it is handed. The accessory cells check this rather than
+ /// tinting unconditionally — see `accessoryScore`.
+ @Environment(\.widgetRenderingMode) private var renderingMode
let entry: NOOPEntry
private var snap: WidgetSnapshot { entry.snapshot }
@@ -97,37 +101,75 @@ struct NOOPWidgetView: View {
/// Lock-Screen rectangular accessory: Charge · Effort · Rest, same trio as the Home Screen rings.
private var rectangular: some View {
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- Text("NOOP")
- .font(.system(size: 11, weight: .bold))
- .foregroundStyle(StrandPalette.textSecondary)
- Spacer(minLength: 0)
- if let bpm = snap.bpm {
- Text("\(bpm) bpm")
- .font(.caption2)
- .foregroundStyle(StrandPalette.textTertiary)
- }
+ // The lock screen gives this family roughly 72pt of height for everything. A "NOOP" title spent
+ // a whole row of that restating which widget the user chose to add, leaving the three scores —
+ // the only reason to add it — squeezed underneath. The title is gone and the heart-rate line is
+ // now conditional, so with no live HR the scores get the entire area.
+ VStack(spacing: 2) {
+ if let bpm = snap.bpm {
+ Text("\(bpm) bpm")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
HStack(alignment: .top, spacing: 0) {
- accessoryScore("Charge", text: snap.recovery.map { "\($0)%" }, tint: chargeColor)
- accessoryScore("Effort", text: effortText, tint: effortColor)
- accessoryScore("Rest", text: snap.rest.map { "\($0)%" }, tint: restColor)
+ accessoryScore("Charge", symbol: "figure.mind.and.body",
+ text: snap.recovery.map { "\($0)%" }, tint: chargeColor)
+ accessoryScore("Effort", symbol: "figure.strengthtraining.traditional",
+ text: effortText, tint: effortColor)
+ accessoryScore("Rest", symbol: "moon.fill",
+ text: snap.rest.map { "\($0)%" }, tint: restColor)
}
}
}
- private func accessoryScore(_ label: String, text: String?, tint: Color) -> some View {
- VStack(alignment: .leading, spacing: 0) {
+ /// One score cell. On the LOCK SCREEN the domain tint is deliberately dropped; it survives only
+ /// where the system actually renders full colour, which for this family means a gallery preview.
+ ///
+ /// Lock-screen widgets render in `.vibrant` (or `.accented`), where the system desaturates whatever
+ /// colour it is given and maps it onto the wallpaper. A domain colour handed to it does not survive
+ /// as that colour — it lands as an arbitrary grey whose luminance nobody chose, so Charge, Effort and
+ /// Rest stopped being distinguishable AND stopped being legible. `.primary`/`.secondary` are the two
+ /// levels the system is designed to map, so the value reads at full strength and the glyph above it
+ /// recedes, which is the hierarchy the tint was there to express in the first place.
+ ///
+ /// The `.fullColor` branch is defensive rather than hot: this family renders `.vibrant` on the lock
+ /// screen and in StandBy, so the tint realistically only reaches a gallery preview.
+ private func accessoryScore(_ label: String, symbol: String, text: String?, tint: Color) -> some View {
+ VStack(spacing: 1) {
+ // Glyph over value, the shape the request asked for. A 9pt word under each number was
+ // spending scarce height on text nobody needs twice — the icons carry the metric identity.
+ Image(systemName: symbol)
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(renderingMode == .fullColor
+ ? AnyShapeStyle(StrandPalette.textTertiary)
+ : AnyShapeStyle(HierarchicalShapeStyle.secondary))
Text(text ?? "–")
- .font(.system(size: 16, weight: .semibold, design: .rounded))
- .foregroundStyle(text == nil ? StrandPalette.textTertiary : tint)
+ .font(.system(size: 15, weight: .semibold, design: .rounded))
+ .foregroundStyle(scoreStyle(hasValue: text != nil, tint: tint))
.minimumScaleFactor(0.7)
- Text(label)
- .font(.system(size: 9))
- .foregroundStyle(StrandPalette.textTertiary)
}
- .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(maxWidth: .infinity)
+ // An icon says nothing to VoiceOver, and the word it replaced was the only thing naming this
+ // metric. Collapse the cell to one element that still speaks "Charge, 68 percent".
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text(label))
+ // Plain literal, not String(localized:). This extension's sources are StrandiOSWidgets +
+ // StrandiOSShared only — Strand/Resources/Localizable.xcstrings is NOT in the target, and
+ // String(localized:) resolves against Bundle.main, which for an app extension is the extension's
+ // own bundle. It would compile, look localized, and render English in every locale. Every other
+ // string in this file is a bare literal for the same reason: the widget is not localized yet.
+ .accessibilityValue(Text(text ?? "No data"))
+ }
+
+ private func scoreStyle(hasValue: Bool, tint: Color) -> AnyShapeStyle {
+ // Spelled out rather than leaning on leading-dot inference through AnyShapeStyle's generic
+ // init, which is the kind of expression that type-checks in a playground and not in a build.
+ guard renderingMode == .fullColor else {
+ return hasValue ? AnyShapeStyle(HierarchicalShapeStyle.primary)
+ : AnyShapeStyle(HierarchicalShapeStyle.secondary)
+ }
+ return hasValue ? AnyShapeStyle(tint) : AnyShapeStyle(StrandPalette.textTertiary)
}
// MARK: - Home Screen: systemSmall
@@ -167,10 +209,18 @@ struct NOOPWidgetView: View {
scoreRings(diameter: 88, lineWidth: 8, labelFont: .caption)
Divider()
HStack(alignment: .top, spacing: 0) {
- statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms")
- statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm")
- statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm")
- statCell("Battery", value: snap.batteryPct.map { "\($0)%" })
+ statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms",
+ name: "Heart rate variability",
+ spoken: snap.hrv.map { "\($0) milliseconds" })
+ statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm",
+ name: "Resting heart rate",
+ spoken: snap.restingHr.map { "\($0) beats per minute" })
+ statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm",
+ name: "Heart rate",
+ spoken: snap.bpm.map { "\($0) beats per minute" })
+ statCell("Battery", value: snap.batteryPct.map { "\($0)%" },
+ name: "Strap battery",
+ spoken: snap.batteryPct.map { "\($0) percent" })
}
Spacer(minLength: 0)
}
@@ -231,24 +281,66 @@ struct NOOPWidgetView: View {
.frame(maxWidth: .infinity)
}
+ /// Home-Screen footer: heart rate, HRV, strap battery, under the score rings.
+ ///
+ /// `heart.fill` is HR and `waveform.path.ecg` is HRV, which is the metric pairing the rest of the app
+ /// uses — `TodayView` renders them as adjacent rows that way, and `DashboardCards`,
+ /// `TodayCustomizationMetadata`, `MetricCatalog` and this extension's own Live Activity all agree.
+ /// The two were inverted here from #1022 until #1795 reported it.
+ ///
+ /// The inversion was an easy one to make and is worth naming so it is not "corrected" back:
+ /// `waveform.path.ecg` IS the right symbol for Live HR as a FEATURE — `RootView`, `RootTabView` and
+ /// `HomeScreenQuickActions` all use it for the Live screen. As a METRIC icon it belongs to HRV, and
+ /// this footer is the one place that renders both metrics side by side, where the collision is the
+ /// entire problem: nothing here carries a text label, so the glyph is the only thing naming a number.
private func vitalsFooter(compact: Bool) -> some View {
HStack {
- Label(snap.bpm.map(String.init) ?? "–", systemImage: "waveform.path.ecg")
+ vital(symbol: "heart.fill", text: snap.bpm.map(String.init),
+ name: "Heart rate", spoken: snap.bpm.map { "\($0) beats per minute" })
Spacer()
if !compact, let hrv = snap.hrv {
- Label("\(hrv)", systemImage: "heart.fill")
+ vital(symbol: "waveform.path.ecg", text: "\(hrv)",
+ name: "Heart rate variability", spoken: "\(hrv) milliseconds")
Spacer()
}
- Label(snap.batteryPct.map { "\($0)%" } ?? "–", systemImage: "battery.50")
+ vital(symbol: "battery.50", text: snap.batteryPct.map { "\($0)%" },
+ name: "Strap battery", spoken: snap.batteryPct.map { "\($0) percent" })
}
.font(.caption2)
.foregroundStyle(StrandPalette.textSecondary)
.labelStyle(.titleAndIcon)
}
+ /// One footer vital. `spoken` is carried separately from the rendered `text` because VoiceOver gets
+ /// neither of the two things a sighted reader uses here: the glyph that names the metric, and the
+ /// unit, which the footer shows for none of them. Without this a reader hears "58", "64", "84 percent"
+ /// — three anonymous numbers. Same reasoning as `accessoryScore`, which #1715 fixed for the lock
+ /// screen; this footer predates it.
+ ///
+ /// Plain literals rather than `String(localized:)`, for the reason spelled out on `accessoryScore`:
+ /// this extension does not carry the app's string catalog, so `String(localized:)` would look
+ /// localized and render English anyway.
+ private func vital(symbol: String, text: String?, name: String, spoken: String?) -> some View {
+ Label(text ?? "–", systemImage: symbol)
+ // Collapse first, like `accessoryScore` and `WidgetScoreRing` already do. A `Label` under
+ // `.titleAndIcon` renders an image beside a text, so without this the bare number stays its
+ // own element and whether the label below wins is SwiftUI container semantics rather than
+ // something this file decides. Every other labelled graphic here ignores its children.
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text(name))
+ .accessibilityValue(Text(spoken ?? "No data"))
+ }
+
/// One labelled stat in the large grid — value over a caption, equal-width so the columns align.
+ ///
+ /// `name` and `spoken` exist because the on-screen caption is abbreviated for width and the unit is a
+ /// separate view: read as-is, VoiceOver produces "64", "ms", "HRV" — three fragments, value before
+ /// label, with "ms" and "bpm" spelled out letter by letter. Collapsing to one element lets the cell
+ /// speak "Heart rate variability, 64 milliseconds". `spoken` falls back to the rendered value rather
+ /// than to "No data", so a caller that omits it degrades to the old reading instead of lying.
private func statCell(_ label: String, value: String?, unit: String? = nil,
- tint: Color = StrandPalette.textPrimary) -> some View {
+ tint: Color = StrandPalette.textPrimary,
+ name: String? = nil, spoken: String? = nil) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(value ?? "–")
@@ -261,6 +353,9 @@ struct NOOPWidgetView: View {
Text(label).font(.caption2).foregroundStyle(StrandPalette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text(name ?? label))
+ .accessibilityValue(Text(spoken ?? value ?? "No data"))
}
}
diff --git a/StrandiOSWidgets/NOOPWidgetBundle.swift b/StrandiOSWidgets/NOOPWidgetBundle.swift
index d1a12cf054..58ee16e311 100644
--- a/StrandiOSWidgets/NOOPWidgetBundle.swift
+++ b/StrandiOSWidgets/NOOPWidgetBundle.swift
@@ -1,11 +1,15 @@
import WidgetKit
import SwiftUI
-/// The widget extension entry point. Bundles the glanceable widget and the live-HR Live Activity.
+/// The widget extension entry point. Bundles the glanceable widget, the live-HR Live Activity,
+/// the K10 Coach brief widget (stored morning brief on Lock Screen / Home Screen), and the
+/// heart-rate trace widget (#1957).
@main
struct NOOPWidgetBundle: WidgetBundle {
var body: some Widget {
NOOPWidget()
NOOPLiveActivity()
+ CoachBriefWidget()
+ HeartRateWidget()
}
}
diff --git a/Tools/doc_comment_lint.py b/Tools/doc_comment_lint.py
index a82755c2a6..b25c32c920 100644
--- a/Tools/doc_comment_lint.py
+++ b/Tools/doc_comment_lint.py
@@ -130,7 +130,11 @@ def main() -> int:
scanned += 1
found = findings(path)
if found:
- rel = str(path.relative_to(ROOT))
+ # as_posix(), not str(): the baseline is written with forward slashes, so on Windows
+ # str() yields "android\app\..." and every baseline lookup misses. That made the gate fail
+ # on a clean tree there (every grandfathered site read as a regression, and every
+ # baselined file as IMPROVED -> 0), so a Windows contributor could not run it locally.
+ rel = path.relative_to(ROOT).as_posix()
by_file[rel] = [f"{rel}:{ln} {why}" for ln, why in found]
regressions = [h for f, hs in sorted(by_file.items())
diff --git a/Tools/doc_comment_lint_baseline.txt b/Tools/doc_comment_lint_baseline.txt
index 1a6ea29e44..87dffc43b9 100644
--- a/Tools/doc_comment_lint_baseline.txt
+++ b/Tools/doc_comment_lint_baseline.txt
@@ -3,7 +3,6 @@
# file not listed here has any. Ratchet DOWN as they are fixed — never up.
# Generated at the time Tools/doc_comment_lint.py landed; see #846 for the pattern.
-android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt 1
android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt 5
android/app/src/main/java/com/noop/ble/WhoopBleClient.kt 8
android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt 1
diff --git a/Tools/i18n/ar_wip.json b/Tools/i18n/ar_wip.json
new file mode 100644
index 0000000000..b649ca0906
--- /dev/null
+++ b/Tools/i18n/ar_wip.json
@@ -0,0 +1,4634 @@
+{
+ "_note": "WORK IN PROGRESS - machine-translated Arabic for Android, NOT a shipping locale. Each entry stores the ENGLISH SOURCE alongside the Arabic on purpose: 856 of these 1147 keys embed a sha1 of the English text, so editing that copy changes the key and would silently orphan the translation. Re-match on 'en' if a key no longer resolves. The stored 'en' is the RAW value from values/strings.xml, so it keeps that file's backslash escapes (69 entries contain \\' for an apostrophe); re-match with the same extraction rather than against rendered text.",
+ "_status": {
+ "translated": 1147,
+ "still_to_translate": 1312,
+ "untranslatable_english_fallback": 121,
+ "base_english_strings": 2581
+ },
+ "_glossary": {
+ "Charge": "الشحن",
+ "Effort": "الجهد",
+ "Rest": "الراحة",
+ "Recovery": "التعافي",
+ "Strain": "الإجهاد",
+ "Sleep": "النوم",
+ "Readiness": "الجاهزية",
+ "HRV": "تقلب النبض",
+ "heart rate": "معدل النبض",
+ "bpm": "نبضة/دقيقة",
+ "strap": "السوار",
+ "baseline": "خط الأساس",
+ "Workout": "تمرين",
+ "Workouts": "التمارين",
+ "Steps": "الخطوات",
+ "Calories": "السعرات",
+ "Today": "اليوم",
+ "Trends": "الاتجاهات",
+ "Insights": "الرؤى",
+ "Journal": "اليوميات",
+ "Settings": "الإعدادات",
+ "Sleep Debt": "دين النوم",
+ "Nap": "قيلولة",
+ "Battery": "البطارية",
+ "Live": "مباشر",
+ "Devices": "الأجهزة",
+ "Automations": "الأتمتة",
+ "Stress": "التوتر",
+ "Breathe": "التنفس",
+ "Hydration": "الترطيب",
+ "Coach": "المدرب الذكي",
+ "Explore": "استكشاف"
+ },
+ "strings": {
+ "action_breathe": {
+ "en": "Breathe",
+ "ar": "التنفس"
+ },
+ "action_live_hr": {
+ "en": "Live HR",
+ "ar": "النبض المباشر"
+ },
+ "action_log_journal": {
+ "en": "Log journal",
+ "ar": "تسجيل اليوميات"
+ },
+ "action_start_workout": {
+ "en": "Start workout",
+ "ar": "بدء تمرين"
+ },
+ "battery_bedtime_body": {
+ "en": "%1$s of recording left, but tonight needs about %2$s. Charge before bed.",
+ "ar": "يتبقى %1$s من التسجيل، لكن الليلة تحتاج نحو %2$s. اشحن السوار قبل النوم."
+ },
+ "battery_bedtime_title": {
+ "en": "Won\\'t last the night",
+ "ar": "لن يكفي طوال الليل"
+ },
+ "battery_channel_desc": {
+ "en": "Alerts when the strap battery is low or fully charged.",
+ "ar": "تنبيهات عند انخفاض بطارية السوار أو اكتمال شحنها."
+ },
+ "battery_channel_name": {
+ "en": "Battery alerts",
+ "ar": "تنبيهات البطارية"
+ },
+ "battery_critical_body": {
+ "en": "%1$d%% left. The strap stops recording near 10%% — it won\\'t capture tonight unless you charge it.",
+ "ar": "يتبقى %1$d%%. يتوقف السوار عن التسجيل قرب 10%% — ولن يسجّل هذه الليلة ما لم تشحنه."
+ },
+ "battery_critical_title": {
+ "en": "Charge your WHOOP now",
+ "ar": "اشحن WHOOP الآن"
+ },
+ "battery_full_body": {
+ "en": "Your WHOOP is at 100%.",
+ "ar": "شحن WHOOP بلغ 100%."
+ },
+ "battery_full_title": {
+ "en": "Strap fully charged",
+ "ar": "اكتمل شحن السوار"
+ },
+ "battery_low_body": {
+ "en": "Recharge your WHOOP before tonight.",
+ "ar": "أعد شحن WHOOP قبل الليلة."
+ },
+ "battery_low_title": {
+ "en": "Low battery",
+ "ar": "البطارية منخفضة"
+ },
+ "battery_runtime_body": {
+ "en": "%1$s left on your WHOOP — recharge tonight.",
+ "ar": "يتبقى %1$s في WHOOP — أعد شحنه الليلة."
+ },
+ "battery_runtime_title": {
+ "en": "Strap battery low",
+ "ar": "بطارية السوار منخفضة"
+ },
+ "breath_presence_intro_body": {
+ "en": "Consciously Connected Breathing (CCB): inhale and exhale in one continuous loop with no hold at the top or bottom. Find a comfortable rhythm — connectedness matters more than intensity. Typical practice is about 15 minutes, twice daily. Regular is the sustainable starter tempo; Mid is a quicker start for later rounds; Punching Through is only when you need to push through drowsiness or stuckness (not a beginner default).",
+ "ar": "التنفس المتصل الواعي (CCB): شهيق وزفير في حلقة متصلة واحدة دون حبس في الأعلى أو الأسفل. اعثر على إيقاع مريح — الاتصال أهم من الشدة. الممارسة المعتادة نحو 15 دقيقة مرتين يوميًا. «العادي» هو الإيقاع المستدام للبداية؛ و«المتوسط» بداية أسرع للجولات اللاحقة؛ و«الاختراق» فقط عند الحاجة إلى تجاوز النعاس أو الجمود (ليس الخيار الافتراضي للمبتدئين)."
+ },
+ "breath_presence_intro_title": {
+ "en": "The Presence Process",
+ "ar": "عملية الحضور"
+ },
+ "breath_proto_bhastrika_caution": {
+ "en": "Stop if dizzy. Avoid with high blood pressure, pregnancy, or recent surgery unless cleared by a clinician.",
+ "ar": "توقف عند الشعور بالدوار. تجنّبه مع ارتفاع ضغط الدم أو الحمل أو بعد جراحة حديثة ما لم يأذن الطبيب."
+ },
+ "breath_proto_bhastrika_edu": {
+ "en": "Bhastrika (bellows): forceful 1-second nasal inhale and exhale. Energising and intense. Keep rounds short and rest between them.",
+ "ar": "باستريكا (المنفاخ): شهيق وزفير أنفي قوي لمدة ثانية واحدة. منشّط وشديد. أبقِ الجولات قصيرة مع راحة بينها."
+ },
+ "breath_proto_bhastrika_session_hint": {
+ "en": "Short rounds only.",
+ "ar": "جولات قصيرة فقط."
+ },
+ "breath_proto_bhastrika_subtitle": {
+ "en": "Forceful 1s in · 1s out",
+ "ar": "قوي · ثانية شهيق · ثانية زفير"
+ },
+ "breath_proto_box_4_4_4_4_edu": {
+ "en": "Box (square) breathing: inhale, hold, exhale, hold — each for 4 seconds. A structured rhythm often used for focus and settling under stress. Sit comfortably and keep the counts even.",
+ "ar": "التنفس المربّع: شهيق، حبس، زفير، حبس — أربع ثوانٍ لكل مرحلة. إيقاع منظم يُستخدم غالبًا للتركيز والتهدئة تحت الضغط. اجلس مرتاحًا وحافظ على تساوي العدّات."
+ },
+ "breath_proto_box_4_4_4_4_session_hint": {
+ "en": "About 5 minutes per session; repeat as needed.",
+ "ar": "نحو 5 دقائق لكل جلسة؛ كرّرها حسب الحاجة."
+ },
+ "breath_proto_box_4_4_4_4_subtitle": {
+ "en": "Square breath · steady focus",
+ "ar": "نفَس مربّع · تركيز ثابت"
+ },
+ "breath_proto_buteyko_caution": {
+ "en": "Stay in a comfortable range; never force breath holds.",
+ "ar": "ابقَ ضمن نطاق مريح؛ لا تُجبر حبس النفس أبدًا."
+ },
+ "breath_proto_buteyko_edu": {
+ "en": "Buteyko-style reduced breathing: small nasal inhale (~2.5s), relaxed nasal exhale (~3.5s), short comfortable hold (~3.5s). Keep it quiet and light — not a big gasp.",
+ "ar": "تنفس مخفَّض بأسلوب بوتيكو: شهيق أنفي صغير (~2.5 ث)، زفير أنفي مسترخٍ (~3.5 ث)، ثم حبس قصير مريح (~3.5 ث). أبقِه هادئًا وخفيفًا — لا شهقة كبيرة."
+ },
+ "breath_proto_buteyko_reduced_caution": {
+ "en": "Never push into panic or strong air hunger.",
+ "ar": "لا تدفع نفسك إلى الذعر أو الجوع الشديد للهواء."
+ },
+ "breath_proto_buteyko_reduced_edu": {
+ "en": "Reduced Buteyko-style: ~2s gentle inhale, ~3s exhale, then a 3–5s hold with mild air hunger only. Stay relaxed in the face and shoulders.",
+ "ar": "بوتيكو المخفَّض: شهيق لطيف ~2 ث، زفير ~3 ث، ثم حبس 3–5 ث مع جوع خفيف للهواء فقط. أبقِ الوجه والكتفين مسترخيين."
+ },
+ "breath_proto_buteyko_reduced_session_hint": {
+ "en": "15–20 minutes if comfortable.",
+ "ar": "15–20 دقيقة إن كان مريحًا."
+ },
+ "breath_proto_buteyko_reduced_subtitle": {
+ "en": "Lighter · longer soft hold",
+ "ar": "أخف · حبس ليّن أطول"
+ },
+ "breath_proto_buteyko_reduced_title": {
+ "en": "Buteyko Reduced",
+ "ar": "بوتيكو المخفَّض"
+ },
+ "breath_proto_buteyko_session_hint": {
+ "en": "3–20 minutes, as comfortable.",
+ "ar": "3–20 دقيقة، حسب الراحة."
+ },
+ "breath_proto_buteyko_subtitle": {
+ "en": "Gentle nasal · light air hunger",
+ "ar": "أنفي لطيف · جوع خفيف للهواء"
+ },
+ "breath_proto_buteyko_title": {
+ "en": "Buteyko",
+ "ar": "بوتيكو"
+ },
+ "breath_proto_coherence_5_5_edu": {
+ "en": "Equal inhale and exhale at about 5.5 breaths per minute — a common coherence / resonance starting point. Steady, balanced pacing for settling heart-rate variability during a session.",
+ "ar": "شهيق وزفير متساويان عند نحو 5.5 نفَس في الدقيقة — نقطة انطلاق شائعة للاتساق والرنين. إيقاع ثابت متوازن لتهدئة تقلب النبض خلال الجلسة."
+ },
+ "breath_proto_coherence_5_5_session_hint": {
+ "en": "Try about 10 minutes.",
+ "ar": "جرّب نحو 10 دقائق."
+ },
+ "breath_proto_coherence_5_5_subtitle": {
+ "en": "Equal breath · ~5.5 br/min coherence",
+ "ar": "نفَس متساوٍ · اتساق ~5.5 نفَس/دقيقة"
+ },
+ "breath_proto_coherence_5_5_title": {
+ "en": "Coherence 5.5",
+ "ar": "الاتساق 5.5"
+ },
+ "breath_proto_coherent_6_6_edu": {
+ "en": "Coherent breathing at six seconds in and six seconds out (~5 breaths per minute). A slow equal pace aimed at calm and HRV-friendly rhythm — estimate only, not a clinical reading.",
+ "ar": "تنفس متسق بست ثوانٍ شهيقًا وست ثوانٍ زفيرًا (~5 أنفاس في الدقيقة). إيقاع بطيء متساوٍ يهدف إلى الهدوء وإيقاع ملائم لتقلب النبض — تقدير فقط وليس قراءة سريرية."
+ },
+ "breath_proto_coherent_6_6_session_hint": {
+ "en": "Start around 10 minutes.",
+ "ar": "ابدأ بنحو 10 دقائق."
+ },
+ "breath_proto_coherent_6_6_subtitle": {
+ "en": "6s in · 6s out · ~5 br/min",
+ "ar": "6 ث شهيق · 6 ث زفير · ~5 نفَس/دقيقة"
+ },
+ "breath_proto_coherent_6_6_title": {
+ "en": "Coherent 6-6",
+ "ar": "المتسق 6-6"
+ },
+ "breath_proto_deep_4_2_6_edu": {
+ "en": "Deep breathing with a short pause: inhale 4s, hold 2s, exhale 6s. Breathe through the nose when you can, fill the lungs without forcing, and let the longer exhale slow you down.",
+ "ar": "تنفس عميق مع وقفة قصيرة: شهيق 4 ث، حبس ثانيتان، زفير 6 ث. تنفّس عبر الأنف متى استطعت، واملأ الرئتين دون إجهاد، ودع الزفير الأطول يبطئك."
+ },
+ "breath_proto_deep_4_2_6_session_hint": {
+ "en": "5–10 minutes daily is a common starting point.",
+ "ar": "5–10 دقائق يوميًا نقطة انطلاق شائعة."
+ },
+ "breath_proto_deep_4_2_6_subtitle": {
+ "en": "Slow inhale · soft hold · long exhale",
+ "ar": "شهيق بطيء · حبس ليّن · زفير طويل"
+ },
+ "breath_proto_deep_4_2_6_title": {
+ "en": "Deep Breathing",
+ "ar": "التنفس العميق"
+ },
+ "breath_proto_diaphragmatic_4_2_6_edu": {
+ "en": "Diaphragmatic (belly) breathing: expand the abdomen on the inhale more than the chest, optional short hold, then gently contract on the exhale. Same 4–2–6 timing as deep breathing, with attention on the diaphragm.",
+ "ar": "التنفس الحجابي (البطني): وسّع البطن مع الشهيق أكثر من الصدر، مع حبس قصير اختياري، ثم اسحبه بلطف مع الزفير. التوقيت نفسه 4–2–6 كالتنفس العميق، مع تركيز على الحجاب الحاجز."
+ },
+ "breath_proto_diaphragmatic_4_2_6_session_hint": {
+ "en": "5–10 minutes daily.",
+ "ar": "5–10 دقائق يوميًا."
+ },
+ "breath_proto_diaphragmatic_4_2_6_subtitle": {
+ "en": "Belly breath · soft pause",
+ "ar": "نفَس بطني · وقفة ليّنة"
+ },
+ "breath_proto_diaphragmatic_4_2_6_title": {
+ "en": "Diaphragmatic",
+ "ar": "حجابي"
+ },
+ "breath_proto_four_seven_eight_caution": {
+ "en": "If you feel dizzy or uncomfortable, stop and breathe normally.",
+ "ar": "إذا شعرت بدوار أو انزعاج، توقف وتنفّس بشكل طبيعي."
+ },
+ "breath_proto_four_seven_eight_edu": {
+ "en": "Classic 4-7-8: quiet nasal inhale for 4, hold for 7, long mouth exhale for 8. Often used to wind down. Start with a few cycles; stop if you feel light-headed.",
+ "ar": "الكلاسيكي 4-7-8: شهيق أنفي هادئ لأربع، حبس لسبع، زفير فموي طويل لثمان. يُستخدم غالبًا للاسترخاء. ابدأ بدورات قليلة؛ وتوقف إن شعرت بخفة في الرأس."
+ },
+ "breath_proto_four_seven_eight_session_hint": {
+ "en": "A few minutes is enough; build gradually.",
+ "ar": "بضع دقائق تكفي؛ زدها تدريجيًا."
+ },
+ "breath_proto_four_seven_eight_subtitle": {
+ "en": "Inhale 4 · hold 7 · exhale 8",
+ "ar": "شهيق 4 · حبس 7 · زفير 8"
+ },
+ "breath_proto_holotropic_caution": {
+ "en": "Not for DIY high-intensity sessions if you have trauma history, cardiovascular issues, or pregnancy without professional guidance.",
+ "ar": "غير مناسب لجلسات مكثفة ذاتية إن كان لديك تاريخ صدمة أو مشكلات قلبية وعائية أو حمل، دون إشراف مختص."
+ },
+ "breath_proto_holotropic_edu": {
+ "en": "Holotropic-style work is continuous, often rapid connected breathing in a supported setting. NOOP only offers a session timer and education — not an auto-pacer — because intensity varies widely and is normally facilitated.",
+ "ar": "العمل بأسلوب الهولوتروبيك تنفس متصل مستمر وسريع غالبًا في بيئة مدعومة. يقدّم NOOP مؤقّت جلسة وشرحًا فقط — لا ضابط إيقاع تلقائي — لأن الشدة تتفاوت كثيرًا وتُدار عادة بإشراف."
+ },
+ "breath_proto_holotropic_session_hint": {
+ "en": "Traditional sessions are long; use a short timer here as a check-in only.",
+ "ar": "الجلسات التقليدية طويلة؛ استخدم مؤقّتًا قصيرًا هنا للمتابعة فقط."
+ },
+ "breath_proto_holotropic_subtitle": {
+ "en": "Guided · continuous connected",
+ "ar": "موجّه · متصل مستمر"
+ },
+ "breath_proto_holotropic_title": {
+ "en": "Holotropic",
+ "ar": "هولوتروبيك"
+ },
+ "breath_proto_kapalabhati_caution": {
+ "en": "Skip if pregnant, dizzy, or have uncontrolled hypertension. Stop if strained.",
+ "ar": "تجنّبه أثناء الحمل أو عند الدوار أو مع ارتفاع ضغط غير منضبط. توقف عند الإجهاد."
+ },
+ "breath_proto_kapalabhati_edu": {
+ "en": "Kapalabhati uses rapid, active belly exhales with passive inhales. Timing varies by person — this mode gives you a timer and coaching text only, not a forced metronome. Practice seated, short rounds, rest between.",
+ "ar": "كابالاباتي يستخدم زفيرًا بطنيًا سريعًا نشطًا مع شهيق سلبي. يختلف التوقيت من شخص لآخر — يمنحك هذا الوضع مؤقّتًا ونصًا إرشاديًا فقط، لا إيقاعًا إجباريًا. مارسه جالسًا، بجولات قصيرة وراحة بينها."
+ },
+ "breath_proto_kapalabhati_session_hint": {
+ "en": "Short rounds (about 30–60s) with rests.",
+ "ar": "جولات قصيرة (نحو 30–60 ثانية) مع راحة."
+ },
+ "breath_proto_kapalabhati_subtitle": {
+ "en": "Guided · rapid belly pulses",
+ "ar": "موجّه · نبضات بطنية سريعة"
+ },
+ "breath_proto_kapalabhati_title": {
+ "en": "Kapalabhati",
+ "ar": "كابالاباتي"
+ },
+ "breath_proto_nadi_shodhana_edu": {
+ "en": "Alternate-nostril breathing: inhale left, hold, exhale right, inhale right, hold, exhale left — about 4 seconds each step. Use a finger to gently close one nostril at a time. Labels cue which side; go at a calm pace.",
+ "ar": "التنفس بالمنخرين بالتناوب: شهيق يسار، حبس، زفير يمين، شهيق يمين، حبس، زفير يسار — نحو 4 ثوانٍ لكل خطوة. استخدم إصبعًا لإغلاق منخر واحد برفق في كل مرة. تشير التسميات إلى الجهة؛ حافظ على وتيرة هادئة."
+ },
+ "breath_proto_nadi_shodhana_session_hint": {
+ "en": "5–10 minutes.",
+ "ar": "5–10 دقائق."
+ },
+ "breath_proto_nadi_shodhana_subtitle": {
+ "en": "Nadi Shodhana · left/right cycle",
+ "ar": "نادي شودانا · دورة يسار/يمين"
+ },
+ "breath_proto_nadi_shodhana_title": {
+ "en": "Alternate Nostril",
+ "ar": "تناوب المنخرين"
+ },
+ "breath_proto_presence_mid_edu": {
+ "en": "Mid session tempo (~2.4s / 2.4s) is a starting pace for later passes through the procedure; once settled you may slow deeper or switch to Punching Through.",
+ "ar": "إيقاع الجلسة المتوسط (~2.4 ث / 2.4 ث) وتيرة انطلاق للمرات اللاحقة من العملية؛ وبعد الاستقرار يمكنك التباطؤ أعمق أو الانتقال إلى «الاختراق»."
+ },
+ "breath_proto_presence_mid_session_hint": {
+ "en": "15 minutes once you are using Mid as your starter.",
+ "ar": "15 دقيقة متى صار «المتوسط» بدايتك."
+ },
+ "breath_proto_presence_mid_subtitle": {
+ "en": "CCB · ~2.4s / 2.4s · ~12.4 br/min",
+ "ar": "CCB · ~2.4 ث / 2.4 ث · ~12.4 نفَس/دقيقة"
+ },
+ "breath_proto_presence_mid_title": {
+ "en": "Presence Mid",
+ "ar": "الحضور المتوسط"
+ },
+ "breath_proto_presence_punching_caution": {
+ "en": "Not recommended as a beginner default unless the goal is to overcome drowsiness.",
+ "ar": "لا يُنصح به كخيار افتراضي للمبتدئين إلا إذا كان الهدف تجاوز النعاس."
+ },
+ "breath_proto_presence_punching_edu": {
+ "en": "Punching Through (~1.9s / 1.9s) is for sleepiness or feeling stuck — not the beginner default. After you are “in it”, you may return to a slower, deeper connected breath.",
+ "ar": "«الاختراق» (~1.9 ث / 1.9 ث) للنعاس أو الشعور بالجمود — وليس الخيار الافتراضي للمبتدئين. بعد أن تدخل في الحالة، يمكنك العودة إلى نفَس متصل أبطأ وأعمق."
+ },
+ "breath_proto_presence_punching_session_hint": {
+ "en": "Up to 15 minutes; ease off if strained.",
+ "ar": "حتى 15 دقيقة؛ خفّف عند الإجهاد."
+ },
+ "breath_proto_presence_punching_subtitle": {
+ "en": "CCB · ~1.9s / 1.9s · push-through",
+ "ar": "CCB · ~1.9 ث / 1.9 ث · اختراق"
+ },
+ "breath_proto_presence_punching_title": {
+ "en": "Presence Punching",
+ "ar": "حضور الاختراق"
+ },
+ "breath_proto_presence_regular_edu": {
+ "en": "Regular tempo (~3.8s in / 3.8s out) is the default sustainable pace for a first pass and for staying the full 15 minutes.",
+ "ar": "الإيقاع العادي (~3.8 ث شهيق / 3.8 ث زفير) هو الوتيرة المستدامة الافتراضية للمرة الأولى وللبقاء طوال الخمس عشرة دقيقة."
+ },
+ "breath_proto_presence_regular_session_hint": {
+ "en": "15 minutes, twice daily (Presence Process frame).",
+ "ar": "15 دقيقة مرتين يوميًا (إطار عملية الحضور)."
+ },
+ "breath_proto_presence_regular_subtitle": {
+ "en": "CCB · ~3.8s / 3.8s · ~7.8 br/min",
+ "ar": "CCB · ~3.8 ث / 3.8 ث · ~7.8 نفَس/دقيقة"
+ },
+ "breath_proto_presence_regular_title": {
+ "en": "Presence Regular",
+ "ar": "الحضور العادي"
+ },
+ "breath_proto_qigong_edu": {
+ "en": "Gentle Qi Gong-style pacing: inhale ~4.5s, optional short hold, exhale ~4.5s, optional short hold. Soft belly focus; no force.",
+ "ar": "إيقاع لطيف بأسلوب تشي غونغ: شهيق ~4.5 ث، حبس قصير اختياري، زفير ~4.5 ث، حبس قصير اختياري. تركيز بطني ليّن؛ دون إجبار."
+ },
+ "breath_proto_qigong_session_hint": {
+ "en": "15–30 minutes is traditional; start shorter.",
+ "ar": "15–30 دقيقة تقليديًا؛ ابدأ بأقصر."
+ },
+ "breath_proto_qigong_subtitle": {
+ "en": "Slow in · soft holds · slow out",
+ "ar": "شهيق بطيء · حبس ليّن · زفير بطيء"
+ },
+ "breath_proto_qigong_title": {
+ "en": "Qi Gong Breath",
+ "ar": "نفَس تشي غونغ"
+ },
+ "breath_proto_relax_4_6_edu": {
+ "en": "A simple calming pace: inhale for 4 seconds, exhale for 6. The longer exhale tends to favour a rest-and-digest feel. Use it when you want an easy downshift without holds.",
+ "ar": "وتيرة تهدئة بسيطة: شهيق 4 ثوانٍ، زفير 6. الزفير الأطول يميل إلى تعزيز إحساس الراحة والهضم. استخدمها حين تريد تهدئة سهلة دون حبس."
+ },
+ "breath_proto_relax_4_6_session_hint": {
+ "en": "Try 5–10 minutes.",
+ "ar": "جرّب 5–10 دقائق."
+ },
+ "breath_proto_relax_4_6_subtitle": {
+ "en": "Long exhale · downshift to rest",
+ "ar": "زفير طويل · تهدئة نحو الراحة"
+ },
+ "breath_proto_relax_4_6_title": {
+ "en": "Relax 4-6",
+ "ar": "استرخاء 4-6"
+ },
+ "breath_proto_shamanic_caution": {
+ "en": "Prefer guided settings for intense practice.",
+ "ar": "يُفضَّل الإعداد الموجّه للممارسة المكثفة."
+ },
+ "breath_proto_shamanic_edu": {
+ "en": "Shamanic-style rhythmic breathing is context-dependent and often facilitated. Use this as a quiet timer with education only — set your own safe intensity.",
+ "ar": "التنفس الإيقاعي بأسلوب شاماني يعتمد على السياق ويُدار عادة بإشراف. استخدم هذا كمؤقّت هادئ مع شرح فقط — واضبط شدة آمنة بنفسك."
+ },
+ "breath_proto_shamanic_session_hint": {
+ "en": "Pick a length that feels safe.",
+ "ar": "اختر مدة تشعر أنها آمنة."
+ },
+ "breath_proto_shamanic_subtitle": {
+ "en": "Guided · rhythmic rapid",
+ "ar": "موجّه · إيقاعي سريع"
+ },
+ "breath_proto_shamanic_title": {
+ "en": "Shamanic Breath",
+ "ar": "نفَس شاماني"
+ },
+ "breath_proto_soma_edu": {
+ "en": "Simple rhythmic connected pacing at 2 seconds in and 2 seconds out — useful when you want a steady musical-feel loop without holds.",
+ "ar": "إيقاع متصل بسيط بثانيتين شهيقًا وثانيتين زفيرًا — مفيد حين تريد حلقة ثابتة ذات إحساس موسيقي دون حبس."
+ },
+ "breath_proto_soma_session_hint": {
+ "en": "Often 20+ minutes; pick a length that fits.",
+ "ar": "غالبًا 20 دقيقة أو أكثر؛ اختر مدة تناسبك."
+ },
+ "breath_proto_soma_subtitle": {
+ "en": "2s in · 2s out · rhythmic",
+ "ar": "ثانيتان شهيق · ثانيتان زفير · إيقاعي"
+ },
+ "breath_proto_tummo_caution": {
+ "en": "Advanced. Avoid if pregnant, cardiovascular issues, or unwell. Not medical advice.",
+ "ar": "متقدّم. تجنّبه أثناء الحمل أو مع مشكلات قلبية وعائية أو عند التوعك. ليس نصيحة طبية."
+ },
+ "breath_proto_tummo_edu": {
+ "en": "A paced Tummo-inspired cycle using mid-range timings: short forced exhale, empty hold, slow inhale, full hold, slow exhale. Advanced and intense — keep sessions short and stop if strained.",
+ "ar": "دورة مستوحاة من التومو بتوقيتات متوسطة: زفير قسري قصير، حبس فارغ، شهيق بطيء، حبس ممتلئ، زفير بطيء. متقدّمة وشديدة — أبقِ الجلسات قصيرة وتوقف عند الإجهاد."
+ },
+ "breath_proto_tummo_session_hint": {
+ "en": "5–10 minutes unless experienced.",
+ "ar": "5–10 دقائق ما لم تكن متمرسًا."
+ },
+ "breath_proto_tummo_subtitle": {
+ "en": "Forced exhale · holds · slow in/out",
+ "ar": "زفير قسري · حبس · شهيق/زفير بطيء"
+ },
+ "breath_proto_tummo_title": {
+ "en": "Tummo",
+ "ar": "تومو"
+ },
+ "breath_proto_ujjayi_edu": {
+ "en": "Ujjayi (ocean) breath: slightly constrict the throat so the breath sounds like distant waves. Equal slow inhale and exhale through the nose (~4.5s each).",
+ "ar": "نفَس أوجايي (المحيط): ضيّق الحلق قليلًا حتى يبدو النفَس كأمواج بعيدة. شهيق وزفير بطيئان متساويان عبر الأنف (~4.5 ث لكل منهما)."
+ },
+ "breath_proto_ujjayi_session_hint": {
+ "en": "5–15 minutes.",
+ "ar": "5–15 دقيقة."
+ },
+ "breath_proto_ujjayi_subtitle": {
+ "en": "Ocean breath · soft throat",
+ "ar": "نفَس المحيط · حلق ليّن"
+ },
+ "breath_proto_ujjayi_title": {
+ "en": "Ujjayi",
+ "ar": "أوجايي"
+ },
+ "breath_proto_wim_hof_caution": {
+ "en": "Never practice in water or while driving. Stop if dizzy. Not medical advice.",
+ "ar": "لا تمارسه أبدًا في الماء أو أثناء القيادة. توقف عند الدوار. ليس نصيحة طبية."
+ },
+ "breath_proto_wim_hof_edu": {
+ "en": "Wim Hof–style rounds typically use ~30 deeper breaths, then an empty hold, then a recovery inhale hold (~15s). Because holds and intensity are personal, this entry is guided: follow a trusted protocol you already know; NOOP times the session and shows reminders — it does not force breath holds.",
+ "ar": "جولات بأسلوب فيم هوف تستخدم عادة نحو 30 نفَسًا أعمق، ثم حبسًا فارغًا، ثم حبس شهيق للتعافي (~15 ث). ولأن الحبس والشدة أمران شخصيان، هذا المدخل موجّه: اتبع بروتوكولًا موثوقًا تعرفه؛ يوقّت NOOP الجلسة ويعرض التذكيرات — ولا يفرض حبس النفس."
+ },
+ "breath_proto_wim_hof_session_hint": {
+ "en": "About 15 minutes for a few rounds.",
+ "ar": "نحو 15 دقيقة لبضع جولات."
+ },
+ "breath_proto_wim_hof_subtitle": {
+ "en": "Guided · rounds + holds",
+ "ar": "موجّه · جولات + حبس"
+ },
+ "breath_proto_wim_hof_title": {
+ "en": "Wim Hof",
+ "ar": "فيم هوف"
+ },
+ "breath_stage_belly_in": {
+ "en": "Belly in",
+ "ar": "البطن للداخل"
+ },
+ "breath_stage_belly_out": {
+ "en": "Belly out",
+ "ar": "البطن للخارج"
+ },
+ "breath_stage_exhale_left": {
+ "en": "Exhale left",
+ "ar": "زفير يسار"
+ },
+ "breath_stage_exhale_right": {
+ "en": "Exhale right",
+ "ar": "زفير يمين"
+ },
+ "breath_stage_force_out": {
+ "en": "Force out",
+ "ar": "زفير قسري"
+ },
+ "breath_stage_hold_empty": {
+ "en": "Hold empty",
+ "ar": "حبس فارغ"
+ },
+ "breath_stage_hold_full": {
+ "en": "Hold full",
+ "ar": "حبس ممتلئ"
+ },
+ "breath_stage_inhale_left": {
+ "en": "Inhale left",
+ "ar": "شهيق يسار"
+ },
+ "breath_stage_inhale_right": {
+ "en": "Inhale right",
+ "ar": "شهيق يمين"
+ },
+ "crash_recovery_body": {
+ "en": "The last crash is shown below. You can copy it for a bug report, then try opening the app again.",
+ "ar": "يظهر آخر تعطل أدناه. يمكنك نسخه لتقرير خطأ، ثم محاولة فتح التطبيق مرة أخرى."
+ },
+ "crash_recovery_continue": {
+ "en": "Try opening NOOP",
+ "ar": "محاولة فتح NOOP"
+ },
+ "crash_recovery_copy": {
+ "en": "Copy error",
+ "ar": "نسخ الخطأ"
+ },
+ "crash_recovery_title": {
+ "en": "NOOP stopped unexpectedly",
+ "ar": "توقف NOOP بشكل غير متوقع"
+ },
+ "cycle_day_range": {
+ "en": "· ~day %1$d–%2$d",
+ "ar": "· ~اليوم %1$d–%2$d"
+ },
+ "cycle_day_single": {
+ "en": "· ~day %1$d",
+ "ar": "· ~اليوم %1$d"
+ },
+ "cycle_home_description": {
+ "en": "Log period starts locally and use them to anchor optional cycle estimates.",
+ "ar": "سجّل بدايات الدورة محليًا واستخدمها كمرجع لتقديرات الدورة الاختيارية."
+ },
+ "cycle_home_latest": {
+ "en": "Latest period start",
+ "ar": "آخر بداية دورة"
+ },
+ "cycle_home_learning": {
+ "en": "Learning your pattern",
+ "ar": "جارٍ تعلّم نمطك"
+ },
+ "cycle_home_log_today": {
+ "en": "Log today",
+ "ar": "تسجيل اليوم"
+ },
+ "cycle_home_logged_today": {
+ "en": "Logged today",
+ "ar": "سُجِّل اليوم"
+ },
+ "cycle_home_open": {
+ "en": "Open tracker",
+ "ar": "فتح المتتبّع"
+ },
+ "cycle_home_private": {
+ "en": "Private, on-device tracking",
+ "ar": "تتبّع خاص على الجهاز"
+ },
+ "cycle_home_setup": {
+ "en": "Set up cycle tracking",
+ "ar": "إعداد تتبّع الدورة"
+ },
+ "cycle_home_title": {
+ "en": "Menstrual Cycle",
+ "ar": "الدورة الشهرية"
+ },
+ "cycle_phase_follicular": {
+ "en": "Follicular",
+ "ar": "الطور الجريبي"
+ },
+ "cycle_phase_learning": {
+ "en": "Learning your pattern",
+ "ar": "جارٍ تعلّم نمطك"
+ },
+ "cycle_phase_luteal": {
+ "en": "Luteal",
+ "ar": "الطور الأصفري"
+ },
+ "cycle_phase_mid_cycle": {
+ "en": "Mid-cycle shift",
+ "ar": "تحوّل منتصف الدورة"
+ },
+ "cycle_phase_unclear": {
+ "en": "No clear pattern",
+ "ar": "لا نمط واضح"
+ },
+ "cycle_tracker_already_logged": {
+ "en": "Already logged",
+ "ar": "مُسجَّل بالفعل"
+ },
+ "cycle_tracker_anchor_help": {
+ "en": "This optional date anchors cycle day 1 and is checked against your nightly temperature pattern.",
+ "ar": "هذا التاريخ الاختياري يحدّد اليوم الأول من الدورة ويُقارن بنمط حرارتك الليلية."
+ },
+ "cycle_tracker_cancel": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "cycle_tracker_current_estimate": {
+ "en": "Current estimate",
+ "ar": "التقدير الحالي"
+ },
+ "cycle_tracker_day_range": {
+ "en": "~day %1$d–%2$d",
+ "ar": "~اليوم %1$d–%2$d"
+ },
+ "cycle_tracker_day_single": {
+ "en": "~day %1$d",
+ "ar": "~اليوم %1$d"
+ },
+ "cycle_tracker_delete_all": {
+ "en": "Delete all",
+ "ar": "حذف الكل"
+ },
+ "cycle_tracker_delete_day": {
+ "en": "Delete %1$s",
+ "ar": "حذف %1$s"
+ },
+ "cycle_tracker_delete_message": {
+ "en": "This permanently removes the on-device period-start history. Sensor history is not changed.",
+ "ar": "يزيل هذا نهائيًا سجل بدايات الدورة المخزّن على الجهاز. لا يتغير سجل المستشعرات."
+ },
+ "cycle_tracker_delete_title": {
+ "en": "Delete all logged period starts?",
+ "ar": "حذف كل بدايات الدورة المسجّلة؟"
+ },
+ "cycle_tracker_done": {
+ "en": "Done",
+ "ar": "تم"
+ },
+ "cycle_tracker_empty": {
+ "en": "No period starts logged yet.",
+ "ar": "لم تُسجَّل أي بدايات دورة بعد."
+ },
+ "cycle_tracker_likely_window": {
+ "en": "Likely period window: %1$s–%2$s. This is a range, not a fixed date.",
+ "ar": "نافذة الدورة المرجّحة: %1$s–%2$s. هذا نطاق وليس تاريخًا محددًا."
+ },
+ "cycle_tracker_log_action": {
+ "en": "Log period start",
+ "ar": "تسجيل بداية دورة"
+ },
+ "cycle_tracker_log_heading": {
+ "en": "Log a period start",
+ "ar": "سجّل بداية دورة"
+ },
+ "cycle_tracker_logged_starts": {
+ "en": "Logged starts",
+ "ar": "البدايات المسجّلة"
+ },
+ "cycle_tracker_privacy": {
+ "en": "This never leaves your device unless you export a backup yourself.",
+ "ar": "لا يغادر هذا جهازك أبدًا ما لم تُصدّر نسخة احتياطية بنفسك."
+ },
+ "cycle_tracker_title": {
+ "en": "Cycle tracker",
+ "ar": "متتبّع الدورة"
+ },
+ "deep_timeline_title": {
+ "en": "Deep Timeline",
+ "ar": "الجدول الزمني العميق"
+ },
+ "explore_all_history": {
+ "en": "all history",
+ "ar": "كل السجل"
+ },
+ "explore_average": {
+ "en": "Average",
+ "ar": "المتوسط"
+ },
+ "explore_category_charge": {
+ "en": "Charge",
+ "ar": "الشحن"
+ },
+ "explore_category_effort": {
+ "en": "Effort",
+ "ar": "الجهد"
+ },
+ "explore_category_health": {
+ "en": "Health",
+ "ar": "الصحة"
+ },
+ "explore_category_heart": {
+ "en": "Heart",
+ "ar": "القلب"
+ },
+ "explore_category_mind": {
+ "en": "Mind",
+ "ar": "الذهن"
+ },
+ "explore_category_nutrition": {
+ "en": "Nutrition",
+ "ar": "التغذية"
+ },
+ "explore_category_rest": {
+ "en": "Rest",
+ "ar": "الراحة"
+ },
+ "explore_chart_points": {
+ "en": "Points",
+ "ar": "النقاط"
+ },
+ "explore_chart_window": {
+ "en": "Window",
+ "ar": "النافذة"
+ },
+ "explore_deep_timeline_hint": {
+ "en": "Every second of your day, zoomable.",
+ "ar": "كل ثانية من يومك، قابلة للتكبير."
+ },
+ "explore_delta_vs_prev": {
+ "en": "Δ vs prev",
+ "ar": "Δ مقارنة بالسابق"
+ },
+ "explore_description_charge": {
+ "en": "How recovered you are, led by HRV versus your personal baseline.",
+ "ar": "مدى تعافيك، يقوده تقلب النبض مقارنةً بخط الأساس الخاص بك."
+ },
+ "explore_description_effort": {
+ "en": "Cardiovascular load for the day, on a 0-100 scale (was 0-21).",
+ "ar": "الحمل القلبي الوعائي لليوم، على مقياس 0-100 (كان 0-21)."
+ },
+ "explore_description_rest": {
+ "en": "How restorative your sleep was: duration, efficiency, deep+REM, timing.",
+ "ar": "مدى استعادة نومك: المدة، والكفاءة، والنوم العميق وREM، والتوقيت."
+ },
+ "explore_empty_metric": {
+ "en": "No %1$s recorded yet. Sync your strap to populate this trend.",
+ "ar": "لم يُسجَّل %1$s بعد. زامن سوارك لتعبئة هذا الاتجاه."
+ },
+ "explore_import_body": {
+ "en": "Import your history first. A WHOOP export in Data Sources fills every metric you can explore here in about a minute.",
+ "ar": "استورد سجلك أولًا. ملف تصدير WHOOP في «مصادر البيانات» يملأ كل مقياس يمكنك استكشافه هنا في نحو دقيقة."
+ },
+ "explore_import_title": {
+ "en": "Import your history first",
+ "ar": "استورد سجلك أولًا"
+ },
+ "explore_latest": {
+ "en": "Latest",
+ "ar": "الأحدث"
+ },
+ "explore_max": {
+ "en": "Max",
+ "ar": "الأقصى"
+ },
+ "explore_metric_average_heart_rate": {
+ "en": "Average Heart Rate",
+ "ar": "متوسط معدل النبض"
+ },
+ "explore_metric_calories_in": {
+ "en": "Calories In",
+ "ar": "السعرات المتناولة"
+ },
+ "explore_metric_carbs": {
+ "en": "Carbs",
+ "ar": "الكربوهيدرات"
+ },
+ "explore_metric_fat": {
+ "en": "Fat",
+ "ar": "الدهون"
+ },
+ "explore_metric_max_heart_rate": {
+ "en": "Max Heart Rate",
+ "ar": "أقصى معدل نبض"
+ },
+ "explore_metric_mood": {
+ "en": "Mood",
+ "ar": "المزاج"
+ },
+ "explore_metric_protein": {
+ "en": "Protein",
+ "ar": "البروتين"
+ },
+ "explore_min": {
+ "en": "Min",
+ "ar": "الأدنى"
+ },
+ "explore_n_pts": {
+ "en": "%1$d pts",
+ "ar": "%1$d نقطة"
+ },
+ "explore_no_prior_window": {
+ "en": "no prior %1$s",
+ "ar": "لا %1$s سابقة"
+ },
+ "explore_over_visible_window": {
+ "en": "Over the visible window",
+ "ar": "خلال النافذة الظاهرة"
+ },
+ "explore_pick_metric": {
+ "en": "Pick metric",
+ "ar": "اختر مقياسًا"
+ },
+ "explore_single_reading": {
+ "en": "Only one reading in range — widen the window to see a trend.",
+ "ar": "قراءة واحدة فقط في النطاق — وسّع النافذة لرؤية اتجاه."
+ },
+ "explore_subtitle": {
+ "en": "Every signal, one tap deep.",
+ "ar": "كل إشارة، على بُعد نقرة واحدة."
+ },
+ "explore_summary": {
+ "en": "Summary",
+ "ar": "الملخص"
+ },
+ "explore_vs_prev_window": {
+ "en": "vs prev %1$s",
+ "ar": "مقارنةً بـ %1$s السابقة"
+ },
+ "fast_history_sync": {
+ "en": "Faster history sync (experimental)",
+ "ar": "مزامنة سجل أسرع (تجريبي)"
+ },
+ "fast_history_sync_desc": {
+ "en": "Ask Android for a faster Bluetooth link while your strap hands over its stored history, so a big backlog catches up sooner. Only during the sync itself — your live heart rate and the overnight stream are untouched. New and unproven: if it doesn\\'t help, or costs more battery than it\\'s worth, turn it back off and say so on the issue tracker.",
+ "ar": "يطلب من Android اتصال Bluetooth أسرع بينما يسلّم سوارك سجله المخزّن، حتى يلحق التراكم الكبير أسرع. أثناء المزامنة نفسها فقط — لا يتأثر نبضك المباشر ولا البث الليلي. جديدة وغير مثبتة: إن لم تساعد أو كلّفت بطارية أكثر مما تستحق، أعد إيقافها وأخبرنا في متتبّع المشكلات."
+ },
+ "fast_link_phy": {
+ "en": "Faster Bluetooth link (experimental)",
+ "ar": "اتصال Bluetooth أسرع (تجريبي)"
+ },
+ "fast_link_phy_desc": {
+ "en": "Ask for a faster Bluetooth radio mode while your strap hands over its stored history. Sends the same data in less airtime, so it may sync sooner and use a little less strap battery — but your strap has to support it, and it trades some range for speed. New and unproven: if syncing gets flaky when your phone is further away, turn it back off.",
+ "ar": "يطلب وضع راديو Bluetooth أسرع بينما يسلّم سوارك سجله المخزّن. يرسل البيانات نفسها في وقت بث أقل، فقد تتم المزامنة أسرع وتوفّر قليلًا من بطارية السوار — لكن على سوارك أن يدعمه، وهو يقايض بعض المدى بالسرعة. جديدة وغير مثبتة: إن صارت المزامنة غير مستقرة حين يبتعد هاتفك، أعد إيقافها."
+ },
+ "fgs_action_disconnect": {
+ "en": "Disconnect",
+ "ar": "قطع الاتصال"
+ },
+ "fgs_channel_desc": {
+ "en": "Shown while NOOP keeps your WHOOP connected in the background.",
+ "ar": "يظهر بينما يُبقي NOOP اتصال WHOOP في الخلفية."
+ },
+ "fgs_channel_name": {
+ "en": "Strap connection",
+ "ar": "اتصال السوار"
+ },
+ "fgs_detail_battery": {
+ "en": "Strap %1$d%%",
+ "ar": "السوار %1$d%%"
+ },
+ "fgs_detail_keeping_link": {
+ "en": "Keeping the link open",
+ "ar": "إبقاء الاتصال مفتوحًا"
+ },
+ "fgs_detail_recovery": {
+ "en": "Recovery %1$d%%",
+ "ar": "التعافي %1$d%%"
+ },
+ "fgs_detail_streaming": {
+ "en": "Streaming in the background",
+ "ar": "بث في الخلفية"
+ },
+ "fgs_title_connected": {
+ "en": "Connected to your WHOOP",
+ "ar": "متصل بـ WHOOP"
+ },
+ "fgs_title_reconnecting": {
+ "en": "Reconnecting to your WHOOP…",
+ "ar": "إعادة الاتصال بـ WHOOP…"
+ },
+ "fgs_title_syncing": {
+ "en": "Syncing strap history…",
+ "ar": "مزامنة سجل السوار…"
+ },
+ "ground_truth_add_historical": {
+ "en": "Add past time window",
+ "ar": "إضافة نافذة زمنية سابقة"
+ },
+ "ground_truth_add_marker": {
+ "en": "Add marker",
+ "ar": "إضافة علامة"
+ },
+ "ground_truth_band_connected_paired": {
+ "en": "Band: connected + paired",
+ "ar": "السوار: متصل + مقترن"
+ },
+ "ground_truth_band_connected_pairing": {
+ "en": "Band: connected; pairing",
+ "ar": "السوار: متصل؛ جارٍ الاقتران"
+ },
+ "ground_truth_band_disconnected": {
+ "en": "Band: disconnected",
+ "ar": "السوار: غير متصل"
+ },
+ "ground_truth_band_searching": {
+ "en": "Band: disconnected; searching",
+ "ar": "السوار: غير متصل؛ جارٍ البحث"
+ },
+ "ground_truth_cancel": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "ground_truth_capture_status": {
+ "en": "Capture status",
+ "ar": "حالة التسجيل"
+ },
+ "ground_truth_comment": {
+ "en": "Session comment",
+ "ar": "تعليق الجلسة"
+ },
+ "ground_truth_coverage": {
+ "en": "%1$d/%2$d s · %3$.1f%% · %4$d s missing%5$s",
+ "ar": "%1$d/%2$d ث · %3$.1f%% · %4$d ث مفقودة%5$s"
+ },
+ "ground_truth_coverage_startup": {
+ "en": " · %1$d s startup",
+ "ar": " · %1$d ث بدء"
+ },
+ "ground_truth_delete_all": {
+ "en": "Delete all sessions",
+ "ar": "حذف كل الجلسات"
+ },
+ "ground_truth_delete_all_message": {
+ "en": "All %1$d recorded sessions and their captured raw data will be deleted permanently.",
+ "ar": "ستُحذف نهائيًا كل الجلسات المسجّلة البالغة %1$d وبياناتها الخام."
+ },
+ "ground_truth_delete_all_title": {
+ "en": "Delete all sessions?",
+ "ar": "حذف كل الجلسات؟"
+ },
+ "ground_truth_delete_confirm": {
+ "en": "Delete",
+ "ar": "حذف"
+ },
+ "ground_truth_delete_session": {
+ "en": "Delete session",
+ "ar": "حذف الجلسة"
+ },
+ "ground_truth_delete_session_message": {
+ "en": "The session %1$s and its captured raw data will be deleted permanently.",
+ "ar": "ستُحذف الجلسة %1$s وبياناتها الخام نهائيًا."
+ },
+ "ground_truth_delete_session_title": {
+ "en": "Delete this session?",
+ "ar": "حذف هذه الجلسة؟"
+ },
+ "ground_truth_edit_end": {
+ "en": "Edit end",
+ "ar": "تعديل النهاية"
+ },
+ "ground_truth_edit_marker": {
+ "en": "Edit marker",
+ "ar": "تعديل العلامة"
+ },
+ "ground_truth_edit_start": {
+ "en": "Edit start",
+ "ar": "تعديل البداية"
+ },
+ "ground_truth_export": {
+ "en": "Export session",
+ "ar": "تصدير الجلسة"
+ },
+ "ground_truth_export_failed": {
+ "en": "Couldn’t export session: %1$s",
+ "ar": "تعذّر تصدير الجلسة: %1$s"
+ },
+ "ground_truth_exporting": {
+ "en": "Building export…",
+ "ar": "جارٍ إنشاء التصدير…"
+ },
+ "ground_truth_history_completed": {
+ "en": "History sync completed: %1$s",
+ "ar": "اكتملت مزامنة السجل: %1$s"
+ },
+ "ground_truth_history_none": {
+ "en": "History sync: no completed sync recorded",
+ "ar": "مزامنة السجل: لا توجد مزامنة مكتملة مسجّلة"
+ },
+ "ground_truth_history_running": {
+ "en": "History sync: running (%1$d chunks)",
+ "ar": "مزامنة السجل: جارية (%1$d أجزاء)"
+ },
+ "ground_truth_last_exported": {
+ "en": "Last exported %1$s · export remains available",
+ "ar": "آخر تصدير %1$s · التصدير ما زال متاحًا"
+ },
+ "ground_truth_legacy_no_sensors": {
+ "en": "Legacy session: event export is available, but its strap cannot be identified for sensor export.",
+ "ar": "جلسة قديمة: تصدير الأحداث متاح، لكن يتعذّر تحديد سوارها لتصدير المستشعرات."
+ },
+ "ground_truth_marker_end": {
+ "en": "End",
+ "ar": "نهاية"
+ },
+ "ground_truth_marker_issue": {
+ "en": "Issue",
+ "ar": "مشكلة"
+ },
+ "ground_truth_marker_moment": {
+ "en": "Moment",
+ "ar": "لحظة"
+ },
+ "ground_truth_marker_note": {
+ "en": "Marker note",
+ "ar": "ملاحظة العلامة"
+ },
+ "ground_truth_marker_start": {
+ "en": "Start",
+ "ar": "بداية"
+ },
+ "ground_truth_marker_times": {
+ "en": "Marker %1$s · Now %2$s",
+ "ar": "العلامة %1$s · الآن %2$s"
+ },
+ "ground_truth_no_sessions": {
+ "en": "No sessions recorded yet.",
+ "ar": "لم تُسجَّل أي جلسات بعد."
+ },
+ "ground_truth_open": {
+ "en": "Open raw-data collector",
+ "ar": "فتح جامع البيانات الخام"
+ },
+ "ground_truth_ready_complete": {
+ "en": "Ready to export · complete",
+ "ar": "جاهز للتصدير · مكتمل"
+ },
+ "ground_truth_ready_missing": {
+ "en": "Ready with %1$d s missing · history may repair it",
+ "ar": "جاهز مع %1$d ث مفقودة · قد يصلحها السجل"
+ },
+ "ground_truth_ready_no_imu": {
+ "en": "Ready without IMU data",
+ "ar": "جاهز دون بيانات IMU"
+ },
+ "ground_truth_realtime_imu": {
+ "en": "Realtime IMU: %1$s; %2$d packets / %3$d bytes%4$s",
+ "ar": "IMU مباشر: %1$s؛ %2$d حزمة / %3$d بايت%4$s"
+ },
+ "ground_truth_realtime_last": {
+ "en": "; last %1$s ago",
+ "ar": "؛ آخرها قبل %1$s"
+ },
+ "ground_truth_recording": {
+ "en": "Recording · %1$s",
+ "ar": "تسجيل · %1$s"
+ },
+ "ground_truth_save": {
+ "en": "Save",
+ "ar": "حفظ"
+ },
+ "ground_truth_sensors_behind": {
+ "en": "Decoded 1 Hz sensors: %1$s behind",
+ "ar": "مستشعرات 1 هرتز المفكوكة: متأخرة %1$s"
+ },
+ "ground_truth_sensors_none": {
+ "en": "Decoded 1 Hz sensors: none",
+ "ar": "مستشعرات 1 هرتز المفكوكة: لا شيء"
+ },
+ "ground_truth_sessions": {
+ "en": "Recorded sessions",
+ "ar": "الجلسات المسجّلة"
+ },
+ "ground_truth_start": {
+ "en": "Start raw-data session",
+ "ar": "بدء جلسة بيانات خام"
+ },
+ "ground_truth_stop": {
+ "en": "Stop session",
+ "ar": "إيقاف الجلسة"
+ },
+ "ground_truth_subtitle": {
+ "en": "Record a bounded 100 Hz motion session, recover Bluetooth gaps from strap history, and export the complete timeline.",
+ "ar": "سجّل جلسة حركة محدودة بتردد 100 هرتز، واسترجع فجوات Bluetooth من سجل السوار، وصدّر الجدول الزمني الكامل."
+ },
+ "ground_truth_test_centre_desc": {
+ "en": "Record, annotate, recover, and export a bounded 5/MG 100 Hz motion capture.",
+ "ar": "تسجيل وتوصيف واسترجاع وتصدير التقاط حركة محدود بتردد 100 هرتز لـ 5/MG."
+ },
+ "ground_truth_title": {
+ "en": "5/MG Raw Data Collector",
+ "ar": "جامع البيانات الخام 5/MG"
+ },
+ "ground_truth_waiting_history": {
+ "en": "Waiting for history through %1$s",
+ "ar": "في انتظار السجل حتى %1$s"
+ },
+ "ground_truth_waiting_imu": {
+ "en": "waiting for IMU",
+ "ar": "في انتظار IMU"
+ },
+ "haptics_breathing_label": {
+ "en": "Breathing pacer",
+ "ar": "موجّه إيقاع التنفس"
+ },
+ "haptics_intervals_label": {
+ "en": "Interval timer",
+ "ar": "مؤقّت الفترات"
+ },
+ "haptics_live_session_label": {
+ "en": "Live Session cues",
+ "ar": "تنبيهات الجلسة المباشرة"
+ },
+ "haptics_section_title": {
+ "en": "Haptics",
+ "ar": "الاهتزاز"
+ },
+ "haptics_workout_label": {
+ "en": "Workout start & end",
+ "ar": "بداية التمرين ونهايته"
+ },
+ "health_connect_categories_detail": {
+ "en": "Select the Health Connect categories to import. You can change this later.",
+ "ar": "اختر فئات Health Connect المراد استيرادها. يمكنك تغيير ذلك لاحقًا."
+ },
+ "health_connect_categories_title": {
+ "en": "Choose what NOOP can read",
+ "ar": "اختر ما يمكن لـ NOOP قراءته"
+ },
+ "health_connect_category_activity": {
+ "en": "Activity",
+ "ar": "النشاط"
+ },
+ "health_connect_category_activity_detail": {
+ "en": "Steps, calories, workouts, distance, and VO₂ max",
+ "ar": "الخطوات والسعرات والتمارين والمسافة وVO₂ max"
+ },
+ "health_connect_category_body_composition": {
+ "en": "Body composition",
+ "ar": "تركيب الجسم"
+ },
+ "health_connect_category_body_composition_detail": {
+ "en": "Weight, body fat, and lean mass",
+ "ar": "الوزن ونسبة الدهون والكتلة الصافية"
+ },
+ "health_connect_category_recovery": {
+ "en": "Recovery & wellness",
+ "ar": "التعافي والعافية"
+ },
+ "health_connect_category_recovery_detail": {
+ "en": "Heart rate, HRV, sleep, SpO₂, breathing, and hydration",
+ "ar": "معدل النبض وتقلب النبض والنوم وSpO₂ والتنفس والترطيب"
+ },
+ "illness_alert_title": {
+ "en": "Early warning: take it easy",
+ "ar": "إنذار مبكر: خفّف من نشاطك"
+ },
+ "inactivity_title": {
+ "en": "Move reminder",
+ "ar": "تذكير بالحركة"
+ },
+ "intervals_range_step": {
+ "en": "%1$s – %2$s%3$s · step %4$d",
+ "ar": "%1$s – %2$s%3$s · خطوة %4$d"
+ },
+ "keep_alive_needed_vendor": {
+ "en": "Your phone (%1$s) can stop background apps to save battery, which can make NOOP miss overnight sleep and recovery data. Tap Allow to whitelist NOOP.\\n\\nAndroid will ask “Stop optimising battery usage?” and warn that NOOP may drain your battery — that\\'s its standard wording for any app allowed to run in the background. Tap Allow. The battery goes on the overnight sync you already switched on above; this only stops Android cutting it short.",
+ "ar": "قد يوقف هاتفك (%1$s) التطبيقات العاملة في الخلفية لتوفير البطارية، مما قد يجعل NOOP يفوّت بيانات النوم والتعافي الليلية. اضغط «سماح» لإضافة NOOP إلى الاستثناءات.\\n\\nسيسأل Android «إيقاف تحسين استخدام البطارية؟» وينبّه إلى أن NOOP قد يستنزف البطارية — وهذه صياغته المعتادة لأي تطبيق يُسمح له بالعمل في الخلفية. اضغط «سماح». تُستهلك البطارية في المزامنة الليلية التي فعّلتها بالفعل أعلاه؛ وهذا يمنع Android فقط من قطعها."
+ },
+ "l10n_add_device_wizard_a_whoop_strap_bonds_to_a_d39bdf4a": {
+ "en": "A WHOOP strap bonds to a single device. While it\\'s connected to NOOP it won\\'t stream ",
+ "ar": "يقترن سوار WHOOP بجهاز واحد فقط. وأثناء اتصاله بـ NOOP لن يبثّ "
+ },
+ "l10n_add_device_wizard_add_61cc55aa": {
+ "en": "Add",
+ "ar": "إضافة"
+ },
+ "l10n_add_device_wizard_advanced_i_already_have_my_ring_e87b9b49": {
+ "en": "Advanced: I already have my ring\\'s key",
+ "ar": "متقدّم: لديّ مفتاح خاتمي بالفعل"
+ },
+ "l10n_add_device_wizard_back_b52b36b7": {
+ "en": "Back",
+ "ar": "رجوع"
+ },
+ "l10n_add_device_wizard_beta_is_an_on_device_estimate_128563e0": {
+ "en": "Beta. * is an on-device estimate. Skin temp is a trend versus your own baseline, steps ",
+ "ar": "إصدار تجريبي. * تقدير على الجهاز. حرارة الجلد اتجاه مقارنةً بخط الأساس الخاص بك، والخطوات "
+ },
+ "l10n_add_device_wizard_both_noop_and_the_oura_app_4f219db3": {
+ "en": "Both NOOP and the Oura app can use a ring you own by key, but only one can hold the Bluetooth link at a time.",
+ "ar": "يمكن لكل من NOOP وتطبيق Oura استخدام خاتم تملكه عبر المفتاح، لكن واحدًا فقط يمكنه الاحتفاظ باتصال Bluetooth في كل مرة."
+ },
+ "l10n_add_device_wizard_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_add_device_wizard_close_bbfa773e": {
+ "en": "Close",
+ "ar": "إغلاق"
+ },
+ "l10n_add_device_wizard_connect_to_this_ring_02b9442b": {
+ "en": "Connect to this ring",
+ "ar": "الاتصال بهذا الخاتم"
+ },
+ "l10n_add_device_wizard_continue_2e026239": {
+ "en": "Continue",
+ "ar": "متابعة"
+ },
+ "l10n_add_device_wizard_device_name_79d7a157": {
+ "en": "Device name",
+ "ar": "اسم الجهاز"
+ },
+ "l10n_add_device_wizard_experimental_best_effort_support_we_re_db288aa8": {
+ "en": "Experimental, best-effort support. We\\'re still testing these, so they might not connect on every device. They never make up data, and they\\'ll tell you honestly when live isn\\'t possible.",
+ "ar": "دعم تجريبي بأفضل جهد ممكن. ما زلنا نختبرها، لذا قد لا تتصل على كل جهاز. لا تختلق البيانات أبدًا، وستخبرك بصدق حين يتعذّر البث المباشر."
+ },
+ "l10n_add_device_wizard_i_understand_this_disconnects_the_ring_66bbb125": {
+ "en": "I understand this disconnects the ring from Oura and that NOOP cannot undo it for me. To go back to Oura I would factory-reset the ring again and set it up in the Oura app.",
+ "ar": "أفهم أن هذا يفصل الخاتم عن Oura وأن NOOP لا يستطيع التراجع عن ذلك نيابةً عني. وللعودة إلى Oura سأعيد ضبط الخاتم لإعدادات المصنع وأعدّه في تطبيق Oura."
+ },
+ "l10n_add_device_wizard_i_understand_this_disconnects_the_ring_ccbb3225": {
+ "en": "I understand this disconnects the ring from Oura and that NOOP cannot undo it for me.",
+ "ar": "أفهم أن هذا يفصل الخاتم عن Oura وأن NOOP لا يستطيع التراجع عن ذلك نيابةً عني."
+ },
+ "l10n_add_device_wizard_installing_noop_s_key_and_confirming_aab5cf61": {
+ "en": "Installing NOOP\\'s key and confirming the ring answers only to NOOP. Keep the ring close and do not open the Oura app.",
+ "ar": "جارٍ تثبيت مفتاح NOOP والتأكد من أن الخاتم يستجيب لـ NOOP وحده. أبقِ الخاتم قريبًا ولا تفتح تطبيق Oura."
+ },
+ "l10n_add_device_wizard_keep_the_oura_app_instead_import_60d70411": {
+ "en": "Keep the Oura app instead (import a file)",
+ "ar": "الإبقاء على تطبيق Oura بدلًا من ذلك (استيراد ملف)"
+ },
+ "l10n_add_device_wizard_make_active_75690bb8": {
+ "en": "Make active",
+ "ar": "تعيينه نشطًا"
+ },
+ "l10n_add_device_wizard_make_confirmname_your_active_device_now_90ceb73e": {
+ "en": "Make %1$s your active device now? It will provide your live data. You can change ",
+ "ar": "تعيين %1$s جهازك النشط الآن؟ سيوفّر بياناتك المباشرة. يمكنك التغيير "
+ },
+ "l10n_add_device_wizard_make_sure_it_s_awake_and_8c40e59f": {
+ "en": "Make sure it\\'s awake and not connected elsewhere.",
+ "ar": "تأكد من أنه مستيقظ وغير متصل بمكان آخر."
+ },
+ "l10n_add_device_wizard_make_this_your_active_device_b425485c": {
+ "en": "Make this your active device?",
+ "ar": "تعيين هذا جهازك النشط؟"
+ },
+ "l10n_add_device_wizard_name_signal_signalbars_level_rssi_of_6aa514f6": {
+ "en": "%1$s, signal %2$s of 4",
+ "ar": "%1$s، الإشارة %2$s من 4"
+ },
+ "l10n_add_device_wizard_noop_stores_this_key_only_on_eae4e63f": {
+ "en": "NOOP stores this key only on this device, in the same place it stores your paired bands.",
+ "ar": "يخزّن NOOP هذا المفتاح على هذا الجهاز فقط، في المكان نفسه الذي يخزّن فيه أساورك المقترنة."
+ },
+ "l10n_add_device_wizard_noop_will_install_its_own_key_d3f6321e": {
+ "en": "NOOP will install its own key on the ring and become its owner. The Oura app will no longer control this ring. This is intended and it cannot be undone from NOOP.",
+ "ar": "سيثبّت NOOP مفتاحه الخاص على الخاتم ويصبح مالكه. لن يتحكم تطبيق Oura بهذا الخاتم بعد الآن. هذا مقصود ولا يمكن التراجع عنه من NOOP."
+ },
+ "l10n_add_device_wizard_not_now_e4571490": {
+ "en": "Not now",
+ "ar": "ليس الآن"
+ },
+ "l10n_add_device_wizard_not_showing_up_make_sure_you_60a8a61b": {
+ "en": "Not showing up? Make sure you reset the ring in the Oura app and force-quit it, then tap Rescan. A ring still owned by Oura will not list here.",
+ "ar": "لا يظهر؟ تأكد من إعادة ضبط الخاتم في تطبيق Oura وإغلاق التطبيق تمامًا، ثم اضغط «إعادة البحث». الخاتم الذي ما زالت Oura تملكه لن يظهر هنا."
+ },
+ "l10n_add_device_wizard_one_phone_at_a_time_cff4ff44": {
+ "en": "One phone at a time",
+ "ar": "هاتف واحد في كل مرة"
+ },
+ "l10n_add_device_wizard_oura_ring_e3431536": {
+ "en": "Oura ring",
+ "ar": "خاتم Oura"
+ },
+ "l10n_add_device_wizard_rescan_84661f6a": {
+ "en": "Rescan",
+ "ar": "إعادة البحث"
+ },
+ "l10n_add_device_wizard_ring_key_32_hex_characters_28cb7e4a": {
+ "en": "Ring key, 32 hex characters",
+ "ar": "مفتاح الخاتم، 32 رمزًا ست عشريًا"
+ },
+ "l10n_add_device_wizard_scan_28cba55d": {
+ "en": "Scan",
+ "ar": "بحث"
+ },
+ "l10n_add_device_wizard_scan_for_type_title_be2c98fb": {
+ "en": "Scan for %1$s",
+ "ar": "البحث عن %1$s"
+ },
+ "l10n_add_device_wizard_scan_for_your_ring_e92b3a3b": {
+ "en": "Scan for your ring",
+ "ar": "ابحث عن خاتمك"
+ },
+ "l10n_add_device_wizard_searching_1a6a5ba8": {
+ "en": "Searching…",
+ "ar": "جارٍ البحث…"
+ },
+ "l10n_add_device_wizard_take_over_2c7f5505": {
+ "en": "Take over",
+ "ar": "الاستحواذ"
+ },
+ "l10n_add_device_wizard_take_over_this_ring_cc79ef5e": {
+ "en": "Take over this ring?",
+ "ar": "الاستحواذ على هذا الخاتم؟"
+ },
+ "l10n_add_device_wizard_take_over_this_ring_f199dc22": {
+ "en": "Take over this ring",
+ "ar": "الاستحواذ على هذا الخاتم"
+ },
+ "l10n_add_device_wizard_taking_over_your_ring_b64a3852": {
+ "en": "Taking over your ring",
+ "ar": "جارٍ الاستحواذ على خاتمك"
+ },
+ "l10n_add_device_wizard_that_is_not_a_32_character_d42ca20d": {
+ "en": "That is not a 32-character hex key.",
+ "ar": "هذا ليس مفتاحًا ست عشريًا من 32 رمزًا."
+ },
+ "l10n_add_device_wizard_the_ring_is_not_bricked_to_5efd1b3d": {
+ "en": "The ring is not bricked. To go back to where you started, factory-reset it again and set it ",
+ "ar": "الخاتم ليس معطلًا. وللعودة إلى ما كنت عليه، أعد ضبطه لإعدادات المصنع مرة أخرى وأعدّه "
+ },
+ "l10n_add_device_wizard_try_again_042c862e": {
+ "en": "Try again",
+ "ar": "إعادة المحاولة"
+ },
+ "l10n_add_device_wizard_use_file_import_0e092cb4": {
+ "en": "Use file import",
+ "ar": "استخدام استيراد ملف"
+ },
+ "l10n_add_device_wizard_we_could_not_take_over_this_fec1ce93": {
+ "en": "We could not take over this ring.",
+ "ar": "تعذّر الاستحواذ على هذا الخاتم."
+ },
+ "l10n_add_device_wizard_whoop_5_0_mg_support_is_e452e686": {
+ "en": "WHOOP 5.0 / MG support is newer and still experimental in NOOP.",
+ "ar": "دعم WHOOP 5.0 / MG أحدث وما زال تجريبيًا في NOOP."
+ },
+ "l10n_add_device_wizard_whoop_is_noop_s_primary_fully_ac6fa33b": {
+ "en": "WHOOP is NOOP\\'s primary, fully-supported band. Other heart-rate straps stream live heart rate and HRV, but not WHOOP\\'s deeper sleep and recovery data.",
+ "ar": "WHOOP هو السوار الأساسي المدعوم بالكامل في NOOP. الأساور الأخرى تبثّ معدل النبض وتقلب النبض مباشرةً، لكن دون بيانات النوم والتعافي الأعمق الخاصة بـ WHOOP."
+ },
+ "l10n_app_changelog_5_mg_bonding_on_android_health_c94c46ac": {
+ "en": "5/MG bonding on Android + Health Monitor fix",
+ "ar": "اقتران 5/MG على Android + إصلاح Health Monitor"
+ },
+ "l10n_app_changelog_a_beautiful_new_look_aa8c910c": {
+ "en": "A beautiful new look",
+ "ar": "مظهر جديد جميل"
+ },
+ "l10n_app_changelog_a_big_bug_fix_sweep_with_65335764": {
+ "en": "A big bug-fix sweep, with the Test Centre to back it up",
+ "ar": "موجة كبيرة من إصلاح الأخطاء، بدعم من مركز الاختبار"
+ },
+ "l10n_app_changelog_a_big_one_smarter_sleep_naps_8d7745c6": {
+ "en": "A big one: smarter sleep, naps, more devices, and a load of fixes",
+ "ar": "تحديث كبير: نوم أذكى، وقيلولات، وأجهزة أكثر، وكثير من الإصلاحات"
+ },
+ "l10n_app_changelog_a_bolder_today_screen_4efe8215": {
+ "en": "A bolder Today screen",
+ "ar": "شاشة «اليوم» أكثر جرأة"
+ },
+ "l10n_app_changelog_a_calmer_today_your_charge_explained_e4174303": {
+ "en": "A calmer Today, your Charge explained, and new HRV science under the hood",
+ "ar": "«اليوم» أهدأ، وشرح للشحن، وعلم جديد لتقلب النبض تحت الغطاء"
+ },
+ "l10n_app_changelog_a_cleaner_home_refreshed_design_a_8c91c06b": {
+ "en": "A cleaner home - refreshed design, a new inbox, your photo",
+ "ar": "شاشة رئيسية أنظف — تصميم محدّث، وصندوق وارد جديد، وصورتك"
+ },
+ "l10n_app_changelog_a_fresh_look_new_gold_on_6f379935": {
+ "en": "A fresh look - new gold-on-navy icon",
+ "ar": "مظهر جديد — أيقونة ذهبية على أزرق داكن"
+ },
+ "l10n_app_changelog_a_quick_fix_9cd2b7de": {
+ "en": "A quick fix",
+ "ar": "إصلاح سريع"
+ },
+ "l10n_app_changelog_a_round_of_fixes_reconnect_exports_7700d972": {
+ "en": "A round of fixes - reconnect, exports & Health setup",
+ "ar": "جولة إصلاحات — إعادة الاتصال والتصدير وإعداد Health"
+ },
+ "l10n_app_changelog_a_round_of_fixes_steps_insights_8f8e6010": {
+ "en": "A round of fixes - steps, Insights & Health setup",
+ "ar": "جولة إصلاحات — الخطوات والرؤى وإعداد Health"
+ },
+ "l10n_app_changelog_a_round_of_look_and_feel_196f6ef3": {
+ "en": "A round of look-and-feel polish",
+ "ar": "جولة تحسينات للمظهر والإحساس"
+ },
+ "l10n_app_changelog_a_small_honest_ask_32f2ec41": {
+ "en": "A small, honest ask",
+ "ar": "طلب صغير وصادق"
+ },
+ "l10n_app_changelog_a_smart_wake_alarm_live_workout_0ef814cc": {
+ "en": "A smart wake alarm, live workout mode, an editable Today, and lifting imports",
+ "ar": "منبّه استيقاظ ذكي، ووضع تمرين مباشر، و«اليوم» قابل للتحرير، واستيراد تمارين القوة"
+ },
+ "l10n_app_changelog_a_smoother_dashboard_on_big_histories_3e3a8dea": {
+ "en": "A smoother dashboard on big histories, and a clearer Smart Alarm",
+ "ar": "لوحة أكثر سلاسة مع السجلات الكبيرة، ومنبّه ذكي أوضح"
+ },
+ "l10n_app_changelog_a_sync_chip_on_today_clearer_37682fc9": {
+ "en": "A sync chip on Today, clearer strap-clock warnings, and complete German",
+ "ar": "مؤشر مزامنة في «اليوم»، وتحذيرات أوضح لساعة السوار، وألمانية كاملة"
+ },
+ "l10n_app_changelog_a_whole_new_look_titanium_gold_b40e0108": {
+ "en": "A whole new look - \"Titanium & Gold\"",
+ "ar": "مظهر جديد كليًا — «التيتانيوم والذهب»"
+ },
+ "l10n_app_changelog_a_whoop_5_that_stays_connected_7b490eee": {
+ "en": "A WHOOP 5 that stays connected, your body clock on the Sleep screen, and a Journal that knows No from nothing",
+ "ar": "WHOOP 5 يبقى متصلًا، وساعتك البيولوجية في شاشة النوم، ويوميات تميّز «لا» عن الفراغ"
+ },
+ "l10n_app_changelog_a_whoop_style_today_chart_clearer_9bdd13ff": {
+ "en": "A WHOOP-style Today chart, clearer sleep times - and a big iPhone update",
+ "ar": "رسم «اليوم» بأسلوب WHOOP، وأوقات نوم أوضح — وتحديث كبير لـ iPhone"
+ },
+ "l10n_app_changelog_accuracy_reliability_accessibility_a_big_community_43c3787d": {
+ "en": "Accuracy, reliability & accessibility - a big community-fixes wave",
+ "ar": "الدقة والموثوقية وإمكانية الوصول — موجة كبيرة من إصلاحات المجتمع"
+ },
+ "l10n_app_changelog_an_effort_scale_you_choose_a_a7c2478d": {
+ "en": "An Effort scale you choose, a ring that gets to sleep, and far fewer wasted re-scores",
+ "ar": "مقياس جهد تختاره، وخاتم ينام، وإعادة احتساب مهدرة أقل بكثير"
+ },
+ "l10n_app_changelog_android_bottom_tab_bar_99b41cad": {
+ "en": "Android: bottom tab bar",
+ "ar": "Android: شريط تبويبات سفلي"
+ },
+ "l10n_app_changelog_android_faster_sync_skin_temp_sync_4f774d79": {
+ "en": "Android: faster sync, skin temp, sync status, alarm groundwork",
+ "ar": "Android: مزامنة أسرع، وحرارة الجلد، وحالة المزامنة، وأساس المنبّه"
+ },
+ "l10n_app_changelog_android_gps_route_distance_fix_817a80d6": {
+ "en": "Android GPS route distance fix",
+ "ar": "إصلاح مسافة مسار GPS على Android"
+ },
+ "l10n_app_changelog_android_home_screen_widget_98b0782a": {
+ "en": "Android home-screen widget",
+ "ar": "أداة الشاشة الرئيسية على Android"
+ },
+ "l10n_app_changelog_android_notification_recovery_fix_widget_armour_b84839c6": {
+ "en": "Android: notification recovery fix + widget armour",
+ "ar": "Android: إصلاح استرداد الإشعارات + تحصين الأداة"
+ },
+ "l10n_app_changelog_android_reconnect_guide_a_startup_crash_9900dd7a": {
+ "en": "Android reconnect guide + a startup-crash fix",
+ "ar": "دليل إعادة الاتصال على Android + إصلاح تعطل عند الإقلاع"
+ },
+ "l10n_app_changelog_android_reliable_reconnect_after_a_dropout_c96cb4c0": {
+ "en": "Android: reliable reconnect after a dropout",
+ "ar": "Android: إعادة اتصال موثوقة بعد الانقطاع"
+ },
+ "l10n_app_changelog_android_share_back_to_health_connect_4d87898e": {
+ "en": "Android: share back to Health Connect",
+ "ar": "Android: المشاركة العكسية إلى Health Connect"
+ },
+ "l10n_app_changelog_android_the_widget_now_actually_updates_dc770011": {
+ "en": "Android: the widget now actually updates",
+ "ar": "Android: الأداة تتحدّث فعلًا الآن"
+ },
+ "l10n_app_changelog_android_today_clearer_empty_states_39ad5d2c": {
+ "en": "Android Today: clearer empty states",
+ "ar": "«اليوم» على Android: حالات فارغة أوضح"
+ },
+ "l10n_app_changelog_android_whoop_4_on_newer_firmware_383314db": {
+ "en": "Android: WHOOP 4 on newer firmware now records data",
+ "ar": "Android: WHOOP 4 على البرامج الثابتة الأحدث يسجّل البيانات الآن"
+ },
+ "l10n_app_changelog_apple_health_export_sleep_nights_recovered_1fc04ee2": {
+ "en": "Apple Health export, sleep nights recovered, and imported-ride Effort",
+ "ar": "التصدير إلى Apple Health، واسترجاع ليالي النوم، وجهد الرحلات المستوردة"
+ },
+ "l10n_app_changelog_auto_sync_health_connect_android_698f0129": {
+ "en": "Auto-sync Health Connect (Android)",
+ "ar": "مزامنة تلقائية لـ Health Connect (Android)"
+ },
+ "l10n_app_changelog_background_gps_sleep_time_editing_log_7baa5d7a": {
+ "en": "Background GPS, sleep-time editing, log-ahead, and a sharper Rest tile",
+ "ar": "GPS في الخلفية، وتحرير أوقات النوم، والتسجيل المسبق، وبطاقة راحة أوضح"
+ },
+ "l10n_app_changelog_backup_controls_and_a_lighter_app_a08f0fe4": {
+ "en": "Backup controls, and a lighter app",
+ "ar": "عناصر تحكم بالنسخ الاحتياطي، وتطبيق أخف"
+ },
+ "l10n_app_changelog_backup_restore_fixed_plus_appearance_controls_d0b9c0fe": {
+ "en": "Backup restore fixed, plus appearance controls",
+ "ar": "إصلاح استعادة النسخة الاحتياطية، مع عناصر تحكم بالمظهر"
+ },
+ "l10n_app_changelog_backup_restore_the_wrong_day_fix_ecbfd90d": {
+ "en": "Backup & Restore, the wrong-day fix, and a smarter Test Centre",
+ "ar": "النسخ الاحتياطي والاستعادة، وإصلاح اليوم الخاطئ، ومركز اختبار أذكى"
+ },
+ "l10n_app_changelog_battery_refresh_on_whoop_5_0_0304ac74": {
+ "en": "Battery refresh on WHOOP 5.0/MG (Mac + Android)",
+ "ar": "تحديث البطارية على WHOOP 5.0/MG (Mac + Android)"
+ },
+ "l10n_app_changelog_battery_responsiveness_smarter_sync_lighter_notification_e6949f3c": {
+ "en": "Battery + responsiveness: smarter sync, lighter notification",
+ "ar": "البطارية والاستجابة: مزامنة أذكى، وإشعار أخف"
+ },
+ "l10n_app_changelog_battery_saver_quiets_the_gauges_translated_bdbe8650": {
+ "en": "Battery saver quiets the gauges, translated Android notifications, and instant chart loads",
+ "ar": "موفّر البطارية يهدّئ المؤشرات، وإشعارات Android مترجمة، وتحميل فوري للرسوم"
+ },
+ "l10n_app_changelog_better_diagnostics_for_newer_strap_firmware_a7ef4187": {
+ "en": "Better diagnostics for newer strap firmware - so we can decode it",
+ "ar": "تشخيصات أفضل للبرامج الثابتة الأحدث للسوار — حتى نتمكن من فكّ ترميزها"
+ },
+ "l10n_app_changelog_better_strap_log_diagnostics_122f820c": {
+ "en": "Better strap-log diagnostics",
+ "ar": "تشخيصات أفضل لسجل السوار"
+ },
+ "l10n_app_changelog_big_fix_wave_clock_reconnect_local_a2959a50": {
+ "en": "Big fix wave - clock, reconnect, local LLM, Explore, weight and more",
+ "ar": "موجة إصلاحات كبيرة — الساعة، وإعادة الاتصال، ونموذج لغوي محلي، والاستكشاف، والوزن وغيرها"
+ },
+ "l10n_app_changelog_bluetooth_stream_apple_health_sync_fixes_78e16558": {
+ "en": "Bluetooth stream + Apple Health sync fixes",
+ "ar": "إصلاحات بث Bluetooth ومزامنة Apple Health"
+ },
+ "l10n_app_changelog_board_sweep_battery_days_left_browse_8b78e0b6": {
+ "en": "Board Sweep: battery days-left, browse past weeks, breathing cues, and a pile of fixes",
+ "ar": "تنظيف اللوحة: أيام البطارية المتبقية، وتصفح الأسابيع الماضية، وتنبيهات التنفس، وكومة إصلاحات"
+ },
+ "l10n_app_changelog_broadcast_your_heart_rate_to_garmin_338605c5": {
+ "en": "Broadcast your heart rate to Garmin, Zwift and gym kit",
+ "ar": "بثّ معدل نبضك إلى Garmin وZwift وأجهزة النادي"
+ },
+ "l10n_app_changelog_browse_past_nights_smarter_coach_workout_9fc9bfd2": {
+ "en": "Browse past nights, smarter Coach, workout times, battery & more",
+ "ar": "تصفح الليالي الماضية، ومدرب أذكى، وأوقات التمارين، والبطارية وغيرها"
+ },
+ "l10n_app_changelog_browse_the_last_few_days_interactive_a13e7b2a": {
+ "en": "Browse the last few days, interactive charts, and a Vital Signs screen (Android)",
+ "ar": "تصفح الأيام القليلة الماضية، ورسوم تفاعلية، وشاشة العلامات الحيوية (Android)"
+ },
+ "l10n_app_changelog_bug_fix_sweep_steps_sleep_export_50c60c51": {
+ "en": "Bug-fix sweep: steps, sleep export, and a battery-saving reconnect fix",
+ "ar": "موجة إصلاحات: الخطوات، وتصدير النوم، وإصلاح إعادة اتصال موفّر للبطارية"
+ },
+ "l10n_app_changelog_bug_fixes_effort_the_widget_s_6b807b50": {
+ "en": "Bug fixes: Effort, the widget\\'s day, and Oura reconnect",
+ "ar": "إصلاحات: الجهد، ويوم الأداة، وإعادة اتصال Oura"
+ },
+ "l10n_app_changelog_buzz_whoop_4_in_the_smart_4d17676a": {
+ "en": "Buzz WHOOP 4 in the Smart Alarm",
+ "ar": "اهتزاز WHOOP 4 في المنبّه الذكي"
+ },
+ "l10n_app_changelog_charge_effort_rest_noop_s_own_8c323656": {
+ "en": "Charge, Effort & Rest - NOOP\\'s own scores, out of 100",
+ "ar": "الشحن والجهد والراحة — درجات NOOP الخاصة من 100"
+ },
+ "l10n_app_changelog_charge_shows_calibrating_instead_of_no_51a000e9": {
+ "en": "Charge shows \"Calibrating\" instead of \"No data\" for new straps",
+ "ar": "الشحن يعرض «جارٍ المعايرة» بدلًا من «لا بيانات» للأساور الجديدة"
+ },
+ "l10n_app_changelog_check_for_updates_736b9062": {
+ "en": "Check for updates",
+ "ar": "التحقق من التحديثات"
+ },
+ "l10n_app_changelog_choose_a_12_hour_clock_sleep_8a19db5c": {
+ "en": "Choose a 12-hour clock, sleep from straps that bank no motion, and a strap log that stops guessing",
+ "ar": "اختيار نظام 12 ساعة، ونوم من أساور لا تخزّن حركة، وسجل سوار يتوقف عن التخمين"
+ },
+ "l10n_app_changelog_classic_chart_colours_a_throwback_toggle_e8150db5": {
+ "en": "Classic chart colours - a throwback toggle",
+ "ar": "ألوان الرسوم الكلاسيكية — مفتاح للعودة إلى الماضي"
+ },
+ "l10n_app_changelog_cleaner_live_status_better_sync_diagnostics_00ad8d26": {
+ "en": "Cleaner Live status + better sync diagnostics",
+ "ar": "حالة مباشرة أنظف + تشخيصات مزامنة أفضل"
+ },
+ "l10n_app_changelog_cleaner_score_rings_a_few_fixes_7094ae7b": {
+ "en": "Cleaner score rings + a few fixes",
+ "ar": "حلقات درجات أنظف + بعض الإصلاحات"
+ },
+ "l10n_app_changelog_clearer_answers_when_your_strap_isn_154c8923": {
+ "en": "Clearer answers when your strap isn\\'t banking history",
+ "ar": "إجابات أوضح حين لا يخزّن سوارك السجل"
+ },
+ "l10n_app_changelog_clearer_labels_a_journal_fix_and_96b91b2a": {
+ "en": "Clearer labels, a journal fix, and steadier sleeping heart rate",
+ "ar": "تسميات أوضح، وإصلاح لليوميات، ومعدل نبض أثناء النوم أكثر ثباتًا"
+ },
+ "l10n_app_changelog_clearer_pairing_guidance_for_whoop_5_91a7736b": {
+ "en": "Clearer pairing guidance for WHOOP 5.0/MG",
+ "ar": "إرشادات اقتران أوضح لـ WHOOP 5.0/MG"
+ },
+ "l10n_app_changelog_clearer_sync_status_6431bf19": {
+ "en": "Clearer sync status",
+ "ar": "حالة مزامنة أوضح"
+ },
+ "l10n_app_changelog_clearer_sync_status_a_responsive_compare_ee8925d0": {
+ "en": "Clearer sync status + a responsive Compare screen",
+ "ar": "حالة مزامنة أوضح + شاشة مقارنة متجاوبة"
+ },
+ "l10n_app_changelog_connect_a_heart_rate_strap_early_f12c6ba9": {
+ "en": "Connect a heart-rate strap (early access)",
+ "ar": "توصيل سوار لقياس النبض (وصول مبكر)"
+ },
+ "l10n_app_changelog_connection_sleep_fixes_a_focused_tune_69b01439": {
+ "en": "Connection & sleep fixes - a focused tune-up",
+ "ar": "إصلاحات الاتصال والنوم — ضبط مركّز"
+ },
+ "l10n_app_changelog_continuous_hrv_capture_sharper_overnight_hrv_9a9ec94b": {
+ "en": "Continuous HRV capture - sharper overnight HRV, recovery and sleep",
+ "ar": "التقاط مستمر لتقلب النبض — تقلب نبض وتعافٍ ونوم أدق ليلًا"
+ },
+ "l10n_app_changelog_continuous_workouts_no_longer_split_plus_4db70213": {
+ "en": "Continuous workouts no longer split, plus delete a sleep session",
+ "ar": "التمارين المتصلة لم تعد تنقسم، مع إمكانية حذف جلسة نوم"
+ },
+ "l10n_app_changelog_coupled_view_workouts_rebuilt_journal_numbers_b347f97e": {
+ "en": "Coupled view, workouts rebuilt, journal numbers",
+ "ar": "العرض المزدوج، وإعادة بناء التمارين، وأرقام اليوميات"
+ },
+ "l10n_app_changelog_cross_platform_parity_android_now_scores_86654a6c": {
+ "en": "Cross-platform parity - Android now scores identically to macOS & iOS",
+ "ar": "تكافؤ عبر المنصات — Android يحتسب الآن بشكل مطابق لـ macOS وiOS"
+ },
+ "l10n_app_changelog_date_fixes_ui_polish_clearer_diagnostics_61058e74": {
+ "en": "Date fixes, UI polish & clearer diagnostics",
+ "ar": "إصلاحات التواريخ، وتحسينات الواجهة، وتشخيصات أوضح"
+ },
+ "l10n_app_changelog_date_hygiene_fix_for_straps_with_dc559c6f": {
+ "en": "Date-hygiene fix for straps with a bad clock",
+ "ar": "إصلاح صحة التواريخ للأساور ذات الساعة الخاطئة"
+ },
+ "l10n_app_changelog_deep_history_backlog_drains_without_manual_bb716532": {
+ "en": "Deep history backlog drains without manual taps",
+ "ar": "تراكم السجل العميق يُفرَّغ دون نقرات يدوية"
+ },
+ "l10n_app_changelog_deep_sleep_no_longer_reads_0_80ce3558": {
+ "en": "Deep sleep no longer reads 0 minutes, and a smarter AI Coach",
+ "ar": "النوم العميق لم يعد يظهر 0 دقيقة، ومدرب ذكي أفضل"
+ },
+ "l10n_app_changelog_deep_sleep_that_happens_later_in_702efdf1": {
+ "en": "Deep sleep that happens later in the night no longer reads 0 minutes",
+ "ar": "النوم العميق في وقت متأخر من الليل لم يعد يظهر 0 دقيقة"
+ },
+ "l10n_app_changelog_deep_timeline_you_can_scroll_through_7c04c040": {
+ "en": "Deep Timeline you can scroll through days, faster manual workouts, and a storage clean-up",
+ "ar": "جدول زمني عميق يمكنك تمريره عبر الأيام، وتمارين يدوية أسرع، وتنظيف للتخزين"
+ },
+ "l10n_app_changelog_delete_a_sleep_swipe_to_mark_7083b34d": {
+ "en": "Delete a sleep, swipe to mark read",
+ "ar": "حذف نوم، والسحب للتعليم كمقروء"
+ },
+ "l10n_app_changelog_design_polish_cross_platform_parity_caa8e2c4": {
+ "en": "Design polish & cross-platform parity",
+ "ar": "تحسينات التصميم والتكافؤ عبر المنصات"
+ },
+ "l10n_app_changelog_dynamic_island_toggle_now_actually_turns_df387ee6": {
+ "en": "Dynamic Island toggle now actually turns it off",
+ "ar": "مفتاح Dynamic Island يوقفه فعلًا الآن"
+ },
+ "l10n_app_changelog_editable_naps_a_richer_trends_report_471c1a69": {
+ "en": "Editable naps, a richer Trends report, and better debug export",
+ "ar": "قيلولات قابلة للتحرير، وتقرير اتجاهات أغنى، وتصدير تصحيح أفضل"
+ },
+ "l10n_app_changelog_effort_explains_a_calm_day_zero_08bd9936": {
+ "en": "Effort explains a calm-day zero - and scores on the 5.0/MG",
+ "ar": "الجهد يفسّر صفر اليوم الهادئ — ويحتسب على 5.0/MG"
+ },
+ "l10n_app_changelog_effort_scale_fix_for_imported_data_aad0344d": {
+ "en": "Effort scale fix for imported data",
+ "ar": "إصلاح مقياس الجهد للبيانات المستوردة"
+ },
+ "l10n_app_changelog_estimated_steps_for_your_whoop_4_e961cade": {
+ "en": "Estimated steps for your WHOOP 4.0",
+ "ar": "خطوات تقديرية لـ WHOOP 4.0"
+ },
+ "l10n_app_changelog_everything_a_whole_new_look_hydration_0f648fc3": {
+ "en": "Everything: a whole new look, hydration, automatic workout detection, and smarter sleep",
+ "ar": "كل شيء: مظهر جديد كليًا، والترطيب، والكشف التلقائي عن التمارين، ونوم أذكى"
+ },
+ "l10n_app_changelog_everything_stays_on_your_device_575125e9": {
+ "en": "Everything stays on your device",
+ "ar": "كل شيء يبقى على جهازك"
+ },
+ "l10n_app_changelog_experimental_unlocking_whoop_5_0_mg_d665f836": {
+ "en": "Experimental: unlocking WHOOP 5.0/MG deep data",
+ "ar": "تجريبي: فتح البيانات العميقة لـ WHOOP 5.0/MG"
+ },
+ "l10n_app_changelog_export_your_raw_sensor_data_csv_59cfa009": {
+ "en": "Export your raw sensor data (CSV)",
+ "ar": "تصدير بيانات المستشعرات الخام (CSV)"
+ },
+ "l10n_app_changelog_faster_and_fewer_sharp_edges_6bbd66c7": {
+ "en": "Faster, and fewer sharp edges",
+ "ar": "أسرع، وحواف حادة أقل"
+ },
+ "l10n_app_changelog_faster_smoother_more_languages_and_a_6a8c8837": {
+ "en": "Faster, smoother, more languages, and a big pile of fixes",
+ "ar": "أسرع وأكثر سلاسة، ولغات أكثر، وكومة كبيرة من الإصلاحات"
+ },
+ "l10n_app_changelog_fewer_false_daytime_sleeps_an_android_6bf9029e": {
+ "en": "Fewer false daytime sleeps + an Android sync button",
+ "ar": "نوم نهاري كاذب أقل + زر مزامنة على Android"
+ },
+ "l10n_app_changelog_find_your_strap_log_in_settings_99105f3a": {
+ "en": "Find your strap log in Settings (macOS)",
+ "ar": "اعثر على سجل سوارك في الإعدادات (macOS)"
+ },
+ "l10n_app_changelog_first_release_d5545946": {
+ "en": "First release",
+ "ar": "الإصدار الأول"
+ },
+ "l10n_app_changelog_first_run_terms_acknowledgment_an_explore_081534f5": {
+ "en": "First-run terms acknowledgment + an Explore chart fix",
+ "ar": "إقرار بالشروط عند أول تشغيل + إصلاح رسم الاستكشاف"
+ },
+ "l10n_app_changelog_fix_a_night_with_a_brief_538bfa1d": {
+ "en": "Fix: a night with a brief wake-up showed as separate naps",
+ "ar": "إصلاح: ليلة بها استيقاظ قصير كانت تظهر كقيلولات منفصلة"
+ },
+ "l10n_app_changelog_fix_app_crashing_won_t_open_d4b0acce": {
+ "en": "Fix: app crashing / won\\'t open when Bluetooth is on",
+ "ar": "إصلاح: تعطل التطبيق / عدم فتحه عند تشغيل Bluetooth"
+ },
+ "l10n_app_changelog_fix_bonded_but_no_live_data_fbab40a4": {
+ "en": "Fix: bonded but no live data (Android)",
+ "ar": "إصلاح: مقترن لكن دون بيانات مباشرة (Android)"
+ },
+ "l10n_app_changelog_fix_connecting_a_polar_h10_or_bf013d57": {
+ "en": "Fix: connecting a Polar H10 or other heart-rate strap",
+ "ar": "إصلاح: توصيل Polar H10 أو أساور نبض أخرى"
+ },
+ "l10n_app_changelog_fix_imported_phone_steps_were_being_7c152d2b": {
+ "en": "Fix: imported phone steps were being double-counted",
+ "ar": "إصلاح: خطوات الهاتف المستوردة كانت تُحتسب مرتين"
+ },
+ "l10n_app_changelog_fix_the_android_freeze_after_a_bf7f8d81": {
+ "en": "Fix the Android freeze after a few nights of data",
+ "ar": "إصلاح تجمّد Android بعد بضع ليالٍ من البيانات"
+ },
+ "l10n_app_changelog_fix_your_day_now_follows_your_e772176e": {
+ "en": "Fix: your day now follows your timezone, not UTC",
+ "ar": "إصلاح: يومك يتبع الآن منطقتك الزمنية لا UTC"
+ },
+ "l10n_app_changelog_fixed_imported_data_and_strap_sync_18350836": {
+ "en": "Fixed: imported data and strap sync getting stuck on iOS",
+ "ar": "تم الإصلاح: تعليق البيانات المستوردة ومزامنة السوار على iOS"
+ },
+ "l10n_app_changelog_fixed_iphone_import_and_a_stuck_38876ee9": {
+ "en": "Fixed: iPhone import, and a stuck store now self-heals",
+ "ar": "تم الإصلاح: استيراد iPhone، والمخزن المتوقف يعالج نفسه الآن"
+ },
+ "l10n_app_changelog_fixes_a_false_pairing_refused_warning_7023464e": {
+ "en": "Fixes a false \"pairing refused\" warning (Mac)",
+ "ar": "يصلح تحذير «رُفض الاقتران» الخاطئ (Mac)"
+ },
+ "l10n_app_changelog_fixes_the_experimental_sleep_toggle_now_fc281b7d": {
+ "en": "Fixes: the experimental sleep toggle now works, steps calibration, manual workouts on WHOOP 5/MG, and a sane HRV reading",
+ "ar": "إصلاحات: مفتاح النوم التجريبي يعمل الآن، ومعايرة الخطوات، والتمارين اليدوية على WHOOP 5/MG، وقراءة معقولة لتقلب النبض"
+ },
+ "l10n_app_changelog_fixes_the_insights_tab_crash_plus_042d1702": {
+ "en": "Fixes the Insights-tab crash, plus more accurate HRV",
+ "ar": "يصلح تعطل تبويب الرؤى، مع تقلب نبض أدق"
+ },
+ "l10n_app_changelog_french_whoop_exports_now_import_db598ea4": {
+ "en": "French WHOOP exports now import",
+ "ar": "ملفات تصدير WHOOP الفرنسية تُستورد الآن"
+ },
+ "l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2": {
+ "en": "German, French & Spanish, pull-to-sync on Today, and a wave of polish",
+ "ar": "الألمانية والفرنسية والإسبانية، والسحب للمزامنة في «اليوم»، وموجة تحسينات"
+ },
+ "l10n_app_changelog_gps_tracked_workouts_android_1cf07bd1": {
+ "en": "GPS-tracked workouts (Android)",
+ "ar": "تمارين متتبَّعة بـ GPS (Android)"
+ },
+ "l10n_app_changelog_gps_workout_crash_fix_android_698ee690": {
+ "en": "GPS workout crash fix (Android)",
+ "ar": "إصلاح تعطل تمرين GPS (Android)"
+ },
+ "l10n_app_changelog_hand_correct_your_sleep_times_smaller_6154b854": {
+ "en": "Hand-correct your sleep times + smaller backups",
+ "ar": "تصحيح أوقات نومك يدويًا + نسخ احتياطية أصغر"
+ },
+ "l10n_app_changelog_health_connect_correct_labels_workout_types_fd2fcc34": {
+ "en": "Health Connect: correct labels + workout types (Android)",
+ "ar": "Health Connect: تسميات صحيحة + أنواع التمارين (Android)"
+ },
+ "l10n_app_changelog_health_connect_shows_as_health_connect_39b3cbcb": {
+ "en": "Health Connect shows as Health Connect",
+ "ar": "Health Connect يظهر باسم Health Connect"
+ },
+ "l10n_app_changelog_history_dates_fixed_for_revived_straps_0ec4e53a": {
+ "en": "History dates fixed for revived straps, gestures during sync, clearer pairing",
+ "ar": "إصلاح تواريخ السجل للأساور المستعادة، والإيماءات أثناء المزامنة، واقتران أوضح"
+ },
+ "l10n_app_changelog_honest_labelling_for_whoop_5_mg_8c650225": {
+ "en": "Honest labelling for WHOOP 5/MG deep-data diagnostics",
+ "ar": "تسميات صادقة لتشخيصات البيانات العميقة لـ WHOOP 5/MG"
+ },
+ "l10n_app_changelog_hotfix_making_a_heart_rate_strap_09f87c0a": {
+ "en": "Hotfix - making a heart-rate strap active no longer crashes",
+ "ar": "إصلاح عاجل — تعيين سوار نبض نشطًا لم يعد يسبب تعطلًا"
+ },
+ "l10n_app_changelog_hr_from_the_optical_waveform_an_b8874b42": {
+ "en": "HR from the optical waveform, an early-morning day rollover, and clearer terms",
+ "ar": "معدل النبض من الموجة الضوئية، وانتقال اليوم في الصباح الباكر، وشروط أوضح"
+ },
+ "l10n_app_changelog_hrv_accuracy_fix_oura_sleep_and_4ed31e39": {
+ "en": "HRV accuracy fix, Oura sleep and motion, richer 5/MG decoding, and a stoppable sync",
+ "ar": "إصلاح دقة تقلب النبض، ونوم وحركة Oura، وفكّ ترميز أغنى لـ 5/MG، ومزامنة قابلة للإيقاف"
+ },
+ "l10n_app_changelog_hrv_that_reads_true_and_a_2b09fa43": {
+ "en": "HRV that reads true, and a tidier workout list",
+ "ar": "تقلب نبض يقرأ الحقيقة، وقائمة تمارين أكثر ترتيبًا"
+ },
+ "l10n_app_changelog_import_fixes_both_sources_all_data_92cfed50": {
+ "en": "Import fixes - both sources, all data types",
+ "ar": "إصلاحات الاستيراد — كلا المصدرين، وكل أنواع البيانات"
+ },
+ "l10n_app_changelog_import_polish_mac_whoop_5_optical_5067d90c": {
+ "en": "Import polish (Mac) + WHOOP 5 optical decode",
+ "ar": "تحسينات الاستيراد (Mac) + فكّ الترميز الضوئي لـ WHOOP 5"
+ },
+ "l10n_app_changelog_independent_and_experimental_f9b65317": {
+ "en": "Independent, and experimental",
+ "ar": "مستقل وتجريبي"
+ },
+ "l10n_app_changelog_ios_is_now_a_direct_download_fab6ed69": {
+ "en": "iOS is now a direct download - no Mac or Xcode needed",
+ "ar": "iOS صار تنزيلًا مباشرًا — دون حاجة إلى Mac أو Xcode"
+ },
+ "l10n_app_changelog_iphone_button_label_polish_64244980": {
+ "en": "iPhone button-label polish",
+ "ar": "تحسين تسميات أزرار iPhone"
+ },
+ "l10n_app_changelog_iphone_hotfix_sideloading_works_again_7b90c265": {
+ "en": "iPhone hotfix: sideloading works again",
+ "ar": "إصلاح عاجل لـ iPhone: التثبيت الجانبي يعمل مجددًا"
+ },
+ "l10n_app_changelog_iphone_import_handle_icloud_and_large_2d60298a": {
+ "en": "iPhone import: handle iCloud and large export files",
+ "ar": "استيراد iPhone: التعامل مع iCloud وملفات التصدير الكبيرة"
+ },
+ "l10n_app_changelog_iphone_polish_accessibility_99bba695": {
+ "en": "iPhone polish + accessibility",
+ "ar": "تحسينات iPhone + إمكانية الوصول"
+ },
+ "l10n_app_changelog_iphone_polish_what_s_new_fits_1dad9b15": {
+ "en": "iPhone polish: What\\'s New fits, Today cards align",
+ "ar": "تحسينات iPhone: «ما الجديد» يتّسع، وبطاقات «اليوم» تصطف"
+ },
+ "l10n_app_changelog_iphone_smoother_scrolling_753e0b0d": {
+ "en": "iPhone: smoother scrolling",
+ "ar": "iPhone: تمرير أكثر سلاسة"
+ },
+ "l10n_app_changelog_iphone_workouts_table_fits_the_screen_156a691c": {
+ "en": "iPhone Workouts table fits the screen",
+ "ar": "جدول التمارين على iPhone يتّسع للشاشة"
+ },
+ "l10n_app_changelog_journal_logging_an_imperial_metric_units_a507a5d2": {
+ "en": "Journal logging + an Imperial/Metric units toggle",
+ "ar": "تسجيل اليوميات + مفتاح الوحدات الإمبراطورية/المترية"
+ },
+ "l10n_app_changelog_large_apple_health_imports_no_longer_830fa786": {
+ "en": "Large Apple Health imports no longer crash (iPhone/Mac)",
+ "ar": "عمليات استيراد Apple Health الكبيرة لم تعد تسبب تعطلًا (iPhone/Mac)"
+ },
+ "l10n_app_changelog_last_workouts_tile_fix_301180af": {
+ "en": "Last Workouts tile fix",
+ "ar": "إصلاح بطاقة آخر التمارين"
+ },
+ "l10n_app_changelog_light_theme_noop_in_warm_paper_d5b9f463": {
+ "en": "Light theme - NOOP in warm paper & gold",
+ "ar": "السمة الفاتحة — NOOP بورق دافئ وذهب"
+ },
+ "l10n_app_changelog_light_theme_polish_5add98b2": {
+ "en": "Light theme polish",
+ "ar": "تحسينات السمة الفاتحة"
+ },
+ "l10n_app_changelog_light_theme_tuning_2bbad1f7": {
+ "en": "Light theme tuning",
+ "ar": "ضبط السمة الفاتحة"
+ },
+ "l10n_app_changelog_live_heart_rate_lands_on_today_5cac8768": {
+ "en": "Live heart rate lands on today\\'s chart even when the strap\\'s clock is off (Android)",
+ "ar": "النبض المباشر يظهر في رسم اليوم حتى لو كانت ساعة السوار خاطئة (Android)"
+ },
+ "l10n_app_changelog_live_heart_rate_that_doesn_t_b6b4ca9a": {
+ "en": "Live heart rate that doesn\\'t freeze",
+ "ar": "نبض مباشر لا يتجمّد"
+ },
+ "l10n_app_changelog_local_oura_ring_support_use_your_e928e3c6": {
+ "en": "Local Oura ring support: use your Oura ring with no Oura app (beta)",
+ "ar": "دعم محلي لخاتم Oura: استخدم خاتمك دون تطبيق Oura (تجريبي)"
+ },
+ "l10n_app_changelog_mac_recovery_builds_from_your_strap_883d49ee": {
+ "en": "Mac: recovery builds from your strap alone",
+ "ar": "Mac: التعافي يُحتسب من سوارك وحده"
+ },
+ "l10n_app_changelog_mac_strap_computed_nights_show_in_b0a3ac91": {
+ "en": "Mac: strap-computed nights show in Sleep",
+ "ar": "Mac: الليالي المحتسبة من السوار تظهر في «النوم»"
+ },
+ "l10n_app_changelog_make_noop_yours_theme_colours_and_6bdef2a7": {
+ "en": "Make NOOP yours — theme colours and custom backgrounds, forty more sports with GPS routes, and a calorie heatmap",
+ "ar": "اجعل NOOP لك — ألوان السمة والخلفيات المخصصة، وأربعون رياضة إضافية بمسارات GPS، وخريطة حرارية للسعرات"
+ },
+ "l10n_app_changelog_manage_several_whoop_straps_and_see_8d9b6a84": {
+ "en": "Manage several WHOOP straps - and see what each band does",
+ "ar": "إدارة عدة أساور WHOOP — ومعرفة ما يفعله كل سوار"
+ },
+ "l10n_app_changelog_manual_workouts_edit_dismiss_auto_detected_95ed45b6": {
+ "en": "Manual workouts, edit/dismiss auto-detected ones, and CSV export",
+ "ar": "تمارين يدوية، وتحرير/تجاهل المكتشفة تلقائيًا، وتصدير CSV"
+ },
+ "l10n_app_changelog_manual_workouts_on_whoop_5_0_72908a88": {
+ "en": "Manual workouts on WHOOP 5.0/MG get their calories and strain back",
+ "ar": "التمارين اليدوية على WHOOP 5.0/MG تستعيد سعراتها وإجهادها"
+ },
+ "l10n_app_changelog_mi_band_import_a_big_whoop_eb5382db": {
+ "en": "Mi Band import + a big WHOOP 4.0 sleep fix",
+ "ar": "استيراد Mi Band + إصلاح كبير لنوم WHOOP 4.0"
+ },
+ "l10n_app_changelog_mind_a_daily_mood_check_in_982fdd16": {
+ "en": "Mind - a daily mood check-in - and nutrition import",
+ "ar": "«الذهن» — تسجيل يومي للمزاج — واستيراد التغذية"
+ },
+ "l10n_app_changelog_more_quick_relabel_sports_ba93c38b": {
+ "en": "More quick-relabel sports",
+ "ar": "رياضات أكثر لإعادة التسمية السريعة"
+ },
+ "l10n_app_changelog_more_realistic_calories_import_chart_fixes_d3d56a9e": {
+ "en": "More realistic calories + import & chart fixes",
+ "ar": "سعرات أكثر واقعية + إصلاحات الاستيراد والرسوم"
+ },
+ "l10n_app_changelog_more_reliable_bluetooth_on_newer_android_c7e641dc": {
+ "en": "More reliable Bluetooth on newer Android phones",
+ "ar": "Bluetooth أكثر موثوقية على هواتف Android الأحدث"
+ },
+ "l10n_app_changelog_more_tab_icons_stop_flickering_colour_3de3de9f": {
+ "en": "More-tab icons stop flickering colour",
+ "ar": "أيقونات تبويب «المزيد» توقفت عن وميض اللون"
+ },
+ "l10n_app_changelog_new_a_guide_to_how_your_7afbd7f4": {
+ "en": "New: a guide to how your Charge, Effort and Rest scores work",
+ "ar": "جديد: دليل لكيفية عمل درجات الشحن والجهد والراحة"
+ },
+ "l10n_app_changelog_new_first_run_onboarding_mac_android_243bc0ad": {
+ "en": "New first-run onboarding (Mac + Android)",
+ "ar": "تهيئة جديدة عند أول تشغيل (Mac + Android)"
+ },
+ "l10n_app_changelog_new_use_an_apple_watch_with_4c2c29a6": {
+ "en": "New: use an Apple Watch with NOOP",
+ "ar": "جديد: استخدام Apple Watch مع NOOP"
+ },
+ "l10n_app_changelog_new_week_in_review_a_live_c1457e40": {
+ "en": "New: Week in review, a live body console, fresher charts and more",
+ "ar": "جديد: مراجعة الأسبوع، ولوحة جسد مباشرة، ورسوم أحدث وغيرها"
+ },
+ "l10n_app_changelog_no_more_hr_spike_when_you_7dc052ba": {
+ "en": "No more HR spike when you reopen the app",
+ "ar": "لا مزيد من قفزة النبض عند إعادة فتح التطبيق"
+ },
+ "l10n_app_changelog_noop_grows_up_it_s_not_823bf2e7": {
+ "en": "NOOP grows up: it\\'s not just for WHOOP anymore",
+ "ar": "NOOP يكبر: لم يعد لـ WHOOP فقط"
+ },
+ "l10n_app_changelog_noop_has_a_new_home_01b87522": {
+ "en": "NOOP has a new home",
+ "ar": "لـ NOOP بيت جديد"
+ },
+ "l10n_app_changelog_noop_now_tells_you_when_your_af259fbb": {
+ "en": "NOOP now tells you when your strap isn\\'t saving history - and how to fix it",
+ "ar": "NOOP يخبرك الآن حين لا يحفظ سوارك السجل — وكيف تصلح ذلك"
+ },
+ "l10n_app_changelog_on_demand_hrv_a_haptic_clock_50376585": {
+ "en": "On-demand HRV, a haptic clock, sleep marks & more",
+ "ar": "تقلب نبض عند الطلب، وساعة اهتزازية، وعلامات نوم وغيرها"
+ },
+ "l10n_app_changelog_oneplus_pairing_fix_f731fcb2": {
+ "en": "OnePlus pairing fix",
+ "ar": "إصلاح اقتران OnePlus"
+ },
+ "l10n_app_changelog_open_a_workout_see_what_it_a40fd8a4": {
+ "en": "Open a workout, see what it costs you, and share your trends",
+ "ar": "افتح تمرينًا، وانظر ما يكلّفك، وشارك اتجاهاتك"
+ },
+ "l10n_app_changelog_optimal_strain_alerts_faster_history_sync_6488a27a": {
+ "en": "Optimal-strain alerts, faster history sync, and a wave of accuracy fixes",
+ "ar": "تنبيهات الإجهاد الأمثل، ومزامنة سجل أسرع، وموجة إصلاحات للدقة"
+ },
+ "l10n_app_changelog_optional_inactivity_nudge_3f4ca05a": {
+ "en": "Optional inactivity nudge",
+ "ar": "تنبيه اختياري للخمول"
+ },
+ "l10n_app_changelog_personal_vital_baselines_mac_analytics_parity_744972d1": {
+ "en": "Personal vital baselines + Mac analytics parity",
+ "ar": "خطوط أساس شخصية للعلامات الحيوية + تكافؤ تحليلات Mac"
+ },
+ "l10n_app_changelog_personalized_heart_rate_zones_compare_and_8d89ee2a": {
+ "en": "Personalized heart-rate zones, compare and switch between straps, and more honest HRV, sleep and Oura reads",
+ "ar": "مناطق نبض مخصصة، ومقارنة الأساور والتبديل بينها، وقراءات أصدق لتقلب النبض والنوم وOura"
+ },
+ "l10n_app_changelog_polish_matched_to_the_iphone_mac_cc34126b": {
+ "en": "Polish - matched to the iPhone & Mac design",
+ "ar": "تحسينات — متوائمة مع تصميم iPhone وMac"
+ },
+ "l10n_app_changelog_power_saving_that_protects_your_strap_57a32503": {
+ "en": "Power saving that protects your strap, a Gemini-powered coach on Android, and richer metric detail",
+ "ar": "توفير طاقة يحمي سوارك، ومدرب مدعوم بـ Gemini على Android، وتفاصيل مقاييس أغنى"
+ },
+ "l10n_app_changelog_raw_spo_honest_units_and_a_55e83a59": {
+ "en": "Raw SpO₂, honest units, and a lighter app",
+ "ar": "SpO₂ الخام، ووحدات صادقة، وتطبيق أخف"
+ },
+ "l10n_app_changelog_re_scan_actually_scans_on_android_22198c8c": {
+ "en": "Re-scan actually scans on Android",
+ "ar": "إعادة البحث تبحث فعلًا على Android"
+ },
+ "l10n_app_changelog_readiness_and_the_start_of_whoop_167f38c5": {
+ "en": "Readiness, and the start of WHOOP 5/MG",
+ "ar": "الجاهزية، وبداية دعم WHOOP 5/MG"
+ },
+ "l10n_app_changelog_readiness_shows_its_evidence_and_a_90cf0076": {
+ "en": "Readiness shows its evidence, and a Health Connect distance fix",
+ "ar": "الجاهزية تعرض أدلتها، وإصلاح مسافة Health Connect"
+ },
+ "l10n_app_changelog_reading_more_from_your_whoop_5_1412164a": {
+ "en": "Reading more from your WHOOP 5.0 (Mac)",
+ "ar": "قراءة المزيد من WHOOP 5.0 (Mac)"
+ },
+ "l10n_app_changelog_reconnect_help_for_whoop_5_0_acc779b6": {
+ "en": "Reconnect help for WHOOP 5.0 / MG after a firmware update",
+ "ar": "مساعدة إعادة الاتصال لـ WHOOP 5.0 / MG بعد تحديث البرنامج الثابت"
+ },
+ "l10n_app_changelog_reconnects_automatically_after_an_update_android_b4965179": {
+ "en": "Reconnects automatically after an update (Android)",
+ "ar": "إعادة اتصال تلقائية بعد التحديث (Android)"
+ },
+ "l10n_app_changelog_recovery_builds_from_your_strap_alone_d83dbd67": {
+ "en": "Recovery builds from your strap alone (Android)",
+ "ar": "التعافي يُحتسب من سوارك وحده (Android)"
+ },
+ "l10n_app_changelog_respiratory_rate_skin_temp_in_the_bfe3f4f9": {
+ "en": "Respiratory rate & skin temp in the Trends report",
+ "ar": "معدل التنفس وحرارة الجلد في تقرير الاتجاهات"
+ },
+ "l10n_app_changelog_restart_your_strap_lighter_on_battery_2ccbef88": {
+ "en": "Restart your strap, lighter on battery, and Health Connect on Android 13",
+ "ar": "إعادة تشغيل سوارك، واستهلاك بطارية أقل، وHealth Connect على Android 13"
+ },
+ "l10n_app_changelog_robust_apple_health_import_marginal_radio_27d78c57": {
+ "en": "Robust Apple Health import, marginal-radio HR mode, live HR graph",
+ "ar": "استيراد قوي من Apple Health، ووضع نبض للإشارة الضعيفة، ورسم نبض مباشر"
+ },
+ "l10n_app_changelog_round_rings_73de603c": {
+ "en": "Round rings",
+ "ar": "حلقات دائرية"
+ },
+ "l10n_app_changelog_run_the_ai_coach_on_your_8ac2b395": {
+ "en": "Run the AI Coach on your own model - including fully local",
+ "ar": "شغّل المدرب الذكي على نموذجك الخاص — بما في ذلك محلي بالكامل"
+ },
+ "l10n_app_changelog_scores_live_from_the_strap_9d5d8ae2": {
+ "en": "Scores live from the strap",
+ "ar": "درجات مباشرة من السوار"
+ },
+ "l10n_app_changelog_security_reliability_hardening_b3c9a976": {
+ "en": "Security & reliability hardening",
+ "ar": "تعزيز الأمان والموثوقية"
+ },
+ "l10n_app_changelog_see_everything_the_deep_timeline_a_dbfe539a": {
+ "en": "See Everything: the Deep Timeline, a sleep movement graph, and a big board-clear",
+ "ar": "شاهد كل شيء: الجدول الزمني العميق، ورسم حركة النوم، وتنظيف كبير للوحة"
+ },
+ "l10n_app_changelog_share_strap_logs_and_a_worn_b5aae860": {
+ "en": "Share strap logs, and a worn-status fix",
+ "ar": "مشاركة سجلات السوار، وإصلاح حالة الارتداء"
+ },
+ "l10n_app_changelog_shortcuts_export_duplicates_fixed_nutrition_mood_a5dcdc11": {
+ "en": "Shortcuts-export duplicates fixed; nutrition & mood reach Android charts",
+ "ar": "إصلاح تكرار تصدير الاختصارات؛ والتغذية والمزاج يصلان إلى رسوم Android"
+ },
+ "l10n_app_changelog_shortcuts_on_mac_recovery_in_the_dbcef46a": {
+ "en": "Shortcuts on Mac, recovery in the Android notification",
+ "ar": "الاختصارات على Mac، والتعافي في إشعار Android"
+ },
+ "l10n_app_changelog_skin_temperature_unblocked_on_mac_ios_9cb6276c": {
+ "en": "Skin temperature unblocked on Mac/iOS, plus export fixes",
+ "ar": "حرارة الجلد متاحة على Mac/iOS، مع إصلاحات التصدير"
+ },
+ "l10n_app_changelog_sleep_and_recovery_for_whoop_4_c8f44a56": {
+ "en": "Sleep and recovery for WHOOP 4.0 straps on the firmware we couldn\\'t read",
+ "ar": "النوم والتعافي لأساور WHOOP 4.0 على البرنامج الثابت الذي تعذّرت قراءته"
+ },
+ "l10n_app_changelog_sleep_charge_and_workouts_cleaned_up_e21a5381": {
+ "en": "Sleep, Charge and workouts, cleaned up",
+ "ar": "النوم والشحن والتمارين، بعد تنظيفها"
+ },
+ "l10n_app_changelog_sleep_debt_daytime_stress_a_recovery_a92853f3": {
+ "en": "Sleep-debt, daytime stress, a recovery forecast, and day-by-day navigation",
+ "ar": "دين النوم، وتوتر النهار، وتوقّع التعافي، والتنقل يومًا بيوم"
+ },
+ "l10n_app_changelog_sleep_figures_hr_zones_charging_calibration_b209b698": {
+ "en": "Sleep figures, HR zones, charging & calibration - a big community-driven update",
+ "ar": "أرقام النوم، ومناطق النبض، والشحن والمعايرة — تحديث كبير بقيادة المجتمع"
+ },
+ "l10n_app_changelog_sleep_fix_for_whoop_4_0_c7a1b13d": {
+ "en": "Sleep fix for WHOOP 4.0 + accurate WHOOP 5/MG steps",
+ "ar": "إصلاح النوم لـ WHOOP 4.0 + خطوات دقيقة لـ WHOOP 5/MG"
+ },
+ "l10n_app_changelog_sleep_from_whoop_4_on_more_efe5fd60": {
+ "en": "Sleep from WHOOP 4 on more firmware (Mac)",
+ "ar": "النوم من WHOOP 4 على مزيد من البرامج الثابتة (Mac)"
+ },
+ "l10n_app_changelog_sleep_keep_real_nights_when_the_0acb206a": {
+ "en": "Sleep: keep real nights when the strap comes off",
+ "ar": "النوم: الاحتفاظ بالليالي الحقيقية عند خلع السوار"
+ },
+ "l10n_app_changelog_sleep_properly_sorted_and_an_app_f5e348f0": {
+ "en": "Sleep, properly sorted, and an app that explains itself",
+ "ar": "النوم، مرتَّب كما يجب، وتطبيق يشرح نفسه"
+ },
+ "l10n_app_changelog_sleep_stages_heal_themselves_after_a_e64a730f": {
+ "en": "Sleep stages heal themselves after a sync",
+ "ar": "مراحل النوم تصحّح نفسها بعد المزامنة"
+ },
+ "l10n_app_changelog_sleep_that_was_stuck_in_the_1b6d1307": {
+ "en": "Sleep that was stuck in the archive comes back",
+ "ar": "النوم العالق في الأرشيف يعود"
+ },
+ "l10n_app_changelog_smart_alarm_actually_works_on_android_c9bbf50a": {
+ "en": "Smart alarm actually works on Android",
+ "ar": "المنبّه الذكي يعمل فعلًا على Android"
+ },
+ "l10n_app_changelog_smart_alarm_the_time_you_set_6f5d260e": {
+ "en": "Smart alarm: the time you set is the time that fires",
+ "ar": "المنبّه الذكي: الوقت الذي تضبطه هو الوقت الذي يرنّ فيه"
+ },
+ "l10n_app_changelog_smoother_an_oura_live_hr_fix_e820bfb4": {
+ "en": "Smoother, an Oura live-HR fix, and a big pile of improvements",
+ "ar": "أكثر سلاسة، وإصلاح نبض Oura المباشر، وكومة كبيرة من التحسينات"
+ },
+ "l10n_app_changelog_smoother_during_long_history_syncs_mac_b31b616c": {
+ "en": "Smoother during long history syncs (Mac)",
+ "ar": "أكثر سلاسة أثناء مزامنات السجل الطويلة (Mac)"
+ },
+ "l10n_app_changelog_smoother_explore_charts_and_a_clearer_9598655d": {
+ "en": "Smoother Explore charts, and a clearer way to connect a WHOOP 5.0/MG",
+ "ar": "رسوم استكشاف أكثر سلاسة، وطريقة أوضح لتوصيل WHOOP 5.0/MG"
+ },
+ "l10n_app_changelog_spanish_whoop_exports_now_import_6351a8c6": {
+ "en": "Spanish WHOOP exports now import",
+ "ar": "ملفات تصدير WHOOP الإسبانية تُستورد الآن"
+ },
+ "l10n_app_changelog_split_sleep_every_block_counted_one_3106596d": {
+ "en": "Split sleep: every block counted, one night per day",
+ "ar": "النوم المجزّأ: احتساب كل كتلة، وليلة واحدة لكل يوم"
+ },
+ "l10n_app_changelog_stability_polish_for_v5_7393eaf0": {
+ "en": "Stability & polish for v5",
+ "ar": "استقرار وتحسينات للإصدار 5"
+ },
+ "l10n_app_changelog_start_a_workout_from_the_workouts_3c882d79": {
+ "en": "Start a workout from the Workouts screen, and an honest Smart-alarm note",
+ "ar": "بدء تمرين من شاشة التمارين، وملاحظة صادقة عن المنبّه الذكي"
+ },
+ "l10n_app_changelog_stays_connected_in_the_background_a6b0a94e": {
+ "en": "Stays connected in the background",
+ "ar": "يبقى متصلًا في الخلفية"
+ },
+ "l10n_app_changelog_steadier_bluetooth_on_congested_android_phones_699ad7d3": {
+ "en": "Steadier Bluetooth on congested Android phones",
+ "ar": "Bluetooth أكثر ثباتًا على هواتف Android المزدحمة"
+ },
+ "l10n_app_changelog_steadier_connections_fixes_and_a_nicer_fe1afd7d": {
+ "en": "Steadier connections, fixes, and a nicer sleep view",
+ "ar": "اتصالات أكثر ثباتًا، وإصلاحات، وعرض نوم أجمل"
+ },
+ "l10n_app_changelog_steadier_heart_rate_a_stack_of_e6026074": {
+ "en": "Steadier heart rate + a stack of fixes",
+ "ar": "نبض أكثر ثباتًا + مجموعة إصلاحات"
+ },
+ "l10n_app_changelog_stop_losing_strap_history_we_can_0598d11a": {
+ "en": "Stop losing strap history we can\\'t yet decode - plus a board of fixes",
+ "ar": "التوقف عن فقدان سجل السوار الذي لا نستطيع فكّ ترميزه بعد — مع لوحة إصلاحات"
+ },
+ "l10n_app_changelog_strap_battery_alerts_11f79bda": {
+ "en": "Strap battery alerts",
+ "ar": "تنبيهات بطارية السوار"
+ },
+ "l10n_app_changelog_strap_log_export_on_mac_a_e15f6b60": {
+ "en": "Strap-log export on Mac + a Health Monitor fix",
+ "ar": "تصدير سجل السوار على Mac + إصلاح Health Monitor"
+ },
+ "l10n_app_changelog_strap_log_stays_off_the_system_b8ce2ff5": {
+ "en": "Strap log stays off the system log (Android)",
+ "ar": "سجل السوار لم يعد يظهر في سجل النظام (Android)"
+ },
+ "l10n_app_changelog_switch_between_your_whoop_4_and_f5036092": {
+ "en": "Switch between your WHOOP 4 and 5.0 (Mac + Android)",
+ "ar": "التبديل بين WHOOP 4 و5.0 (Mac + Android)"
+ },
+ "l10n_app_changelog_switching_between_whoop_straps_now_actually_8428faaa": {
+ "en": "Switching between WHOOP straps now actually switches",
+ "ar": "التبديل بين أساور WHOOP يبدّل فعلًا الآن"
+ },
+ "l10n_app_changelog_sync_diagnostics_surfacing_silently_dropped_history_9fe62836": {
+ "en": "Sync diagnostics: surfacing silently-dropped history",
+ "ar": "تشخيصات المزامنة: إظهار السجل المفقود بصمت"
+ },
+ "l10n_app_changelog_sync_visibility_a_sharper_stress_timeline_37e1e6d6": {
+ "en": "Sync visibility + a sharper Stress timeline",
+ "ar": "وضوح المزامنة + جدول زمني أدق للتوتر"
+ },
+ "l10n_app_changelog_the_archived_sleep_recovery_now_reaches_cf4bb3ef": {
+ "en": "The archived-sleep recovery now reaches Android too",
+ "ar": "استرجاع النوم المؤرشف وصل إلى Android أيضًا"
+ },
+ "l10n_app_changelog_the_everything_update_5e08d257": {
+ "en": "The everything update",
+ "ar": "تحديث كل شيء"
+ },
+ "l10n_app_changelog_the_new_look_everywhere_plus_sleep_5793dddf": {
+ "en": "The new look everywhere - plus sleep, Effort & Bluetooth fixes",
+ "ar": "المظهر الجديد في كل مكان — مع إصلاحات النوم والجهد وBluetooth"
+ },
+ "l10n_app_changelog_the_new_today_screen_comes_to_69dfab3d": {
+ "en": "The new Today screen comes to Android",
+ "ar": "شاشة «اليوم» الجديدة تصل إلى Android"
+ },
+ "l10n_app_changelog_the_smoothness_release_faster_everywhere_plus_baf8a7fc": {
+ "en": "The smoothness release: faster everywhere, plus a sleep memory fix",
+ "ar": "إصدار السلاسة: أسرع في كل مكان، مع إصلاح ذاكرة النوم"
+ },
+ "l10n_app_changelog_the_test_centre_help_us_fix_af39aa17": {
+ "en": "The Test Centre: help us fix YOUR specific problem",
+ "ar": "مركز الاختبار: ساعدنا في إصلاح مشكلتك أنت تحديدًا"
+ },
+ "l10n_app_changelog_tidier_today_gauges_5d1fe894": {
+ "en": "Tidier Today gauges",
+ "ar": "مؤشرات «اليوم» أكثر ترتيبًا"
+ },
+ "l10n_app_changelog_tidier_today_hero_strap_renaming_smarter_34493c9c": {
+ "en": "Tidier Today hero, strap renaming, smarter journal",
+ "ar": "صدارة «اليوم» أكثر ترتيبًا، وإعادة تسمية السوار، ويوميات أذكى"
+ },
+ "l10n_app_changelog_tidier_workout_names_correct_rest_duration_135cf67f": {
+ "en": "Tidier workout names, correct Rest duration",
+ "ar": "أسماء تمارين أكثر ترتيبًا، ومدة راحة صحيحة"
+ },
+ "l10n_app_changelog_tidy_your_journal_remove_and_hide_aa1c0b94": {
+ "en": "Tidy your journal - remove and hide questions",
+ "ar": "رتّب يومياتك — احذف الأسئلة وأخفِها"
+ },
+ "l10n_app_changelog_times_follow_your_12_24_hour_9c552783": {
+ "en": "Times follow your 12-/24-hour setting",
+ "ar": "الأوقات تتبع إعداد 12/24 ساعة لديك"
+ },
+ "l10n_app_changelog_today_header_date_fix_west_of_e5e7422d": {
+ "en": "Today header date fix (west-of-UTC)",
+ "ar": "إصلاح تاريخ رأس «اليوم» (غرب UTC)"
+ },
+ "l10n_app_changelog_today_reflects_today_not_stale_imports_1a341a21": {
+ "en": "Today reflects today (not stale imports)",
+ "ar": "«اليوم» يعكس اليوم (لا عمليات استيراد قديمة)"
+ },
+ "l10n_app_changelog_today_s_effort_goes_live_plus_5f810b61": {
+ "en": "Today\\'s Effort goes live - plus sleep & alarm honesty",
+ "ar": "جهد اليوم يصبح مباشرًا — مع صدق في النوم والمنبّه"
+ },
+ "l10n_app_changelog_today_s_effort_no_longer_drops_f49a3428": {
+ "en": "Today\\'s Effort no longer drops to zero",
+ "ar": "جهد اليوم لم يعد يهبط إلى الصفر"
+ },
+ "l10n_app_changelog_today_tiles_no_longer_cut_their_3c167cc1": {
+ "en": "Today tiles no longer cut their value to \"10…\"",
+ "ar": "بطاقات «اليوم» لم تعد تقتطع قيمتها إلى «10…»"
+ },
+ "l10n_app_changelog_today_tiles_no_longer_truncate_their_2959ce81": {
+ "en": "Today tiles no longer truncate their value (Android)",
+ "ar": "بطاقات «اليوم» لم تعد تقتطع قيمتها (Android)"
+ },
+ "l10n_app_changelog_today_trends_stay_within_their_window_ba2e4c57": {
+ "en": "Today trends stay within their window (Mac)",
+ "ar": "اتجاهات «اليوم» تبقى ضمن نافذتها (Mac)"
+ },
+ "l10n_app_changelog_toggle_the_live_hr_dynamic_island_d3bd70cb": {
+ "en": "Toggle the live-HR Dynamic Island",
+ "ar": "مفتاح Dynamic Island للنبض المباشر"
+ },
+ "l10n_app_changelog_track_a_workout_manually_2eb43402": {
+ "en": "Track a workout manually",
+ "ar": "تتبّع تمرين يدويًا"
+ },
+ "l10n_app_changelog_training_load_a_vo_max_without_f9205a67": {
+ "en": "Training load, a VO₂max without a tape measure, and far less battery spent re-scoring",
+ "ar": "حمل التدريب، وVO₂max دون شريط قياس، وبطارية أقل بكثير في إعادة الاحتساب"
+ },
+ "l10n_app_changelog_trends_report_explains_its_scores_e338d1ee": {
+ "en": "Trends report explains its scores",
+ "ar": "تقرير الاتجاهات يشرح درجاته"
+ },
+ "l10n_app_changelog_true_battery_a_sync_indicator_and_89037fc3": {
+ "en": "True battery %, a sync indicator, and HR on imported workouts",
+ "ar": "نسبة بطارية حقيقية %، ومؤشر مزامنة، ونبض في التمارين المستوردة"
+ },
+ "l10n_app_changelog_two_quick_fixes_the_blue_titanium_4c021d78": {
+ "en": "Two quick fixes: the Blue Titanium icon, and Mac \"Your Cards\"",
+ "ar": "إصلاحان سريعان: أيقونة Blue Titanium، و«بطاقاتك» على Mac"
+ },
+ "l10n_app_changelog_under_the_hood_current_api_migration_672da037": {
+ "en": "Under-the-hood: current-API migration (no behaviour change)",
+ "ar": "تحت الغطاء: الانتقال إلى واجهات البرمجة الحالية (دون تغيير في السلوك)"
+ },
+ "l10n_app_changelog_universal_mac_build_iphone_import_fix_363425a0": {
+ "en": "Universal Mac build + iPhone import fix",
+ "ar": "إصدار Mac شامل + إصلاح استيراد iPhone"
+ },
+ "l10n_app_changelog_update_check_shows_what_s_new_6779c2e3": {
+ "en": "Update check shows what\\'s new",
+ "ar": "التحقق من التحديثات يعرض ما الجديد"
+ },
+ "l10n_app_changelog_updates_check_github_again_1e00ced1": {
+ "en": "Updates check GitHub again",
+ "ar": "التحديثات تتحقق من GitHub مجددًا"
+ },
+ "l10n_app_changelog_v5_the_raw_signal_release_noop_c109e3f2": {
+ "en": "v5 - the raw-signal release: NOOP reads the signal, on your device, free",
+ "ar": "الإصدار 5 — إصدار الإشارة الخام: NOOP يقرأ الإشارة، على جهازك، مجانًا"
+ },
+ "l10n_app_changelog_water_and_caffeine_from_apple_health_5123b6b0": {
+ "en": "Water and caffeine from Apple Health, a sharper Effort score, and an Oura resting-heart-rate fix",
+ "ar": "الماء والكافيين من Apple Health، ودرجة جهد أدق، وإصلاح نبض الراحة في Oura"
+ },
+ "l10n_app_changelog_week_in_review_is_honest_about_4c225bd3": {
+ "en": "Week in Review is honest about a half-finished week",
+ "ar": "مراجعة الأسبوع صادقة بشأن أسبوع غير مكتمل"
+ },
+ "l10n_app_changelog_whoop_4_0_is_the_supported_16893d9d": {
+ "en": "WHOOP 4.0 is the supported path",
+ "ar": "WHOOP 4.0 هو المسار المدعوم"
+ },
+ "l10n_app_changelog_whoop_5_0_history_decoding_comes_a8848386": {
+ "en": "WHOOP 5.0 history decoding comes to Android",
+ "ar": "فكّ ترميز سجل WHOOP 5.0 يصل إلى Android"
+ },
+ "l10n_app_changelog_whoop_5_0_mg_buzz_the_c1ab45a8": {
+ "en": "WHOOP 5.0/MG buzz - the real command (matched byte-for-byte)",
+ "ar": "اهتزاز WHOOP 5.0/MG — الأمر الحقيقي (مطابق بايت ببايت)"
+ },
+ "l10n_app_changelog_whoop_5_0_mg_buzz_trying_98373c44": {
+ "en": "WHOOP 5.0/MG buzz - trying the right command (experimental)",
+ "ar": "اهتزاز WHOOP 5.0/MG — تجربة الأمر الصحيح (تجريبي)"
+ },
+ "l10n_app_changelog_whoop_5_0_mg_history_download_36cc4425": {
+ "en": "WHOOP 5.0/MG history download (experimental) + pairing help (Mac)",
+ "ar": "تنزيل سجل WHOOP 5.0/MG (تجريبي) + مساعدة الاقتران (Mac)"
+ },
+ "l10n_app_changelog_whoop_5_0_mg_history_offload_76f5af44": {
+ "en": "WHOOP 5.0/MG history offload (Android)",
+ "ar": "تفريغ سجل WHOOP 5.0/MG (Android)"
+ },
+ "l10n_app_changelog_whoop_5_mg_deep_data_live_2612bf3d": {
+ "en": "WHOOP 5/MG deep data: live confirmation it\\'s working",
+ "ar": "البيانات العميقة لـ WHOOP 5/MG: تأكيد مباشر أنها تعمل"
+ },
+ "l10n_app_changelog_whoop_5_mg_deep_sync_decode_652c34d2": {
+ "en": "WHOOP 5/MG deep-sync decode + sleep & workout fixes",
+ "ar": "فكّ ترميز المزامنة العميقة لـ WHOOP 5/MG + إصلاحات النوم والتمارين"
+ },
+ "l10n_app_changelog_whoop_5_mg_frame_capture_mac_9e84ee52": {
+ "en": "WHOOP 5/MG frame capture (Mac)",
+ "ar": "التقاط إطارات WHOOP 5/MG (Mac)"
+ },
+ "l10n_app_changelog_whoop_5_mg_heart_rate_on_11816e98": {
+ "en": "WHOOP 5/MG heart rate on Android",
+ "ar": "نبض WHOOP 5/MG على Android"
+ },
+ "l10n_app_changelog_whoop_5_mg_heart_rate_on_b66c6752": {
+ "en": "WHOOP 5/MG heart rate on Mac + a Readiness fix",
+ "ar": "نبض WHOOP 5/MG على Mac + إصلاح الجاهزية"
+ },
+ "l10n_app_changelog_whoop_5_mg_history_the_missing_b4d5db99": {
+ "en": "WHOOP 5/MG history: the missing clock",
+ "ar": "سجل WHOOP 5/MG: الساعة المفقودة"
+ },
+ "l10n_app_changelog_whoop_5_mg_secure_pairing_fix_ed6550f3": {
+ "en": "WHOOP 5/MG: secure-pairing fix",
+ "ar": "WHOOP 5/MG: إصلاح الاقتران الآمن"
+ },
+ "l10n_app_changelog_whoop_5_mg_the_buzz_works_a0308753": {
+ "en": "WHOOP 5/MG: the buzz works",
+ "ar": "WHOOP 5/MG: الاهتزاز يعمل"
+ },
+ "l10n_app_changelog_whoop_style_hrv_warm_ups_counted_199aaa42": {
+ "en": "WHOOP-style HRV, warm-ups counted, and clearer cards",
+ "ar": "تقلب نبض بأسلوب WHOOP، واحتساب الإحماء، وبطاقات أوضح"
+ },
+ "l10n_app_changelog_widgets_stop_inventing_numbers_naps_count_4f7491bb": {
+ "en": "Widgets stop inventing numbers, naps count toward sleep debt, and the Android status chips speak your language",
+ "ar": "الأدوات تتوقف عن اختلاق الأرقام، والقيلولات تُحتسب في دين النوم، ومؤشرات Android تتحدث لغتك"
+ },
+ "l10n_app_changelog_workout_calories_for_manual_sessions_and_5d61f59a": {
+ "en": "Workout calories - for manual sessions and Health Connect imports",
+ "ar": "سعرات التمرين — للجلسات اليدوية وعمليات استيراد Health Connect"
+ },
+ "l10n_app_changelog_workout_display_fixes_255b56e8": {
+ "en": "Workout display fixes",
+ "ar": "إصلاحات عرض التمارين"
+ },
+ "l10n_app_changelog_workouts_correct_source_pill_for_health_787243ea": {
+ "en": "Workouts: correct source pill for Health Connect (Android)",
+ "ar": "التمارين: مؤشر مصدر صحيح لـ Health Connect (Android)"
+ },
+ "l10n_app_changelog_workouts_header_layout_fix_phone_fa23fbec": {
+ "en": "Workouts header layout fix (phone)",
+ "ar": "إصلاح تخطيط رأس «التمارين» (الهاتف)"
+ },
+ "l10n_app_changelog_wrist_alerts_for_incoming_calls_android_9bf80a06": {
+ "en": "Wrist alerts for incoming calls (Android)",
+ "ar": "تنبيهات المعصم للمكالمات الواردة (Android)"
+ },
+ "l10n_app_changelog_wrist_alerts_work_on_android_09e544a9": {
+ "en": "Wrist alerts work on Android",
+ "ar": "تنبيهات المعصم تعمل على Android"
+ },
+ "l10n_app_changelog_your_fitness_age_vitality_body_age_8e9867fb": {
+ "en": "Your Fitness Age, Vitality & Body Age",
+ "ar": "عمرك اللياقي، والحيوية، وعمر الجسد"
+ },
+ "l10n_app_changelog_your_imported_steps_now_show_on_d79f0b36": {
+ "en": "Your imported steps now show on the Today screen (Android)",
+ "ar": "خطواتك المستوردة تظهر الآن في شاشة «اليوم» (Android)"
+ },
+ "l10n_app_changelog_your_macos_data_is_back_and_e5e3e3bd": {
+ "en": "Your macOS data is back — and full French",
+ "ar": "بيانات macOS عادت — وفرنسية كاملة"
+ },
+ "l10n_app_changelog_your_scores_build_over_a_few_41388c54": {
+ "en": "Your scores build over a few nights",
+ "ar": "درجاتك تتكوّن عبر بضع ليالٍ"
+ },
+ "l10n_app_changelog_your_whole_day_s_heart_rate_e57593f8": {
+ "en": "Your whole day\\'s heart rate, on the dashboard",
+ "ar": "نبض يومك كاملًا، على اللوحة"
+ },
+ "l10n_app_changelog_your_whoop_journal_in_insights_clearer_88f5e21b": {
+ "en": "Your WHOOP journal in Insights, clearer metric taps",
+ "ar": "يوميات WHOOP في الرؤى، ونقرات مقاييس أوضح"
+ },
+ "l10n_app_root_more_4bab2d8f": {
+ "en": "More",
+ "ar": "المزيد"
+ },
+ "l10n_app_root_this_section_is_on_the_way_ca7c4a32": {
+ "en": "This section is on the way.",
+ "ar": "هذا القسم قادم قريبًا."
+ },
+ "l10n_app_root_updates_c76d1807": {
+ "en": "Updates",
+ "ar": "التحديثات"
+ },
+ "l10n_apple_health_screen_no_readings_recorded_05018b26": {
+ "en": "No readings recorded.",
+ "ar": "لا قراءات مسجّلة."
+ },
+ "l10n_apple_health_screen_nothing_imported_yet_f457cdbe": {
+ "en": "Nothing imported yet",
+ "ar": "لم يُستورد شيء بعد"
+ },
+ "l10n_apple_health_screen_reading_your_apple_health_history_1a3bf76d": {
+ "en": "Reading your Apple Health history…",
+ "ar": "جارٍ قراءة سجل Apple Health…"
+ },
+ "l10n_auto_workout_nudge_dismiss_this_workout_suggestion_52ace8f3": {
+ "en": "Dismiss this workout suggestion",
+ "ar": "تجاهل اقتراح التمرين هذا"
+ },
+ "l10n_auto_workout_nudge_looks_like_a_workout_e745e403": {
+ "en": "Looks like a workout",
+ "ar": "يبدو أنه تمرين"
+ },
+ "l10n_auto_workout_nudge_not_a_workout_15c5f784": {
+ "en": "Not a workout",
+ "ar": "ليس تمرينًا"
+ },
+ "l10n_auto_workout_nudge_save_it_01d23661": {
+ "en": "Save it",
+ "ar": "احفظه"
+ },
+ "l10n_automations_screen_automations_82542d6d": {
+ "en": "Automations",
+ "ar": "الأتمتة"
+ },
+ "l10n_automations_screen_battery_alerts_f3679d60": {
+ "en": "Battery alerts",
+ "ar": "تنبيهات البطارية"
+ },
+ "l10n_automations_screen_buzz_strength_e895f99e": {
+ "en": "Buzz strength",
+ "ar": "قوة الاهتزاز"
+ },
+ "l10n_automations_screen_choose_double_tap_action_6ac9a313": {
+ "en": "Choose double-tap action",
+ "ar": "اختر إجراء النقر المزدوج"
+ },
+ "l10n_automations_screen_detect_short_naps_bbfd136d": {
+ "en": "Detect short naps",
+ "ar": "اكتشاف القيلولات القصيرة"
+ },
+ "l10n_automations_screen_double_tap_8d2f1646": {
+ "en": "Double-tap",
+ "ar": "نقر مزدوج"
+ },
+ "l10n_automations_screen_each_day_uses_the_time_above_f9bc9ce3": {
+ "en": "Each day uses the time above unless you set a different one here.",
+ "ar": "يستخدم كل يوم الوقت أعلاه ما لم تضبط وقتًا مختلفًا هنا."
+ },
+ "l10n_automations_screen_enable_inactivity_reminder_468c3017": {
+ "en": "Enable inactivity reminder",
+ "ar": "تفعيل تذكير الخمول"
+ },
+ "l10n_automations_screen_from_3f66052a": {
+ "en": "From",
+ "ar": "من"
+ },
+ "l10n_automations_screen_haptic_coaching_e2fab286": {
+ "en": "Haptic coaching",
+ "ar": "إرشاد بالاهتزاز"
+ },
+ "l10n_automations_screen_hr_zone_coaching_9306e6e1": {
+ "en": "HR-zone coaching",
+ "ar": "إرشاد مناطق النبض"
+ },
+ "l10n_automations_screen_illness_early_warning_453ab477": {
+ "en": "Illness early-warning",
+ "ar": "إنذار مبكر بالمرض"
+ },
+ "l10n_automations_screen_inactivity_reminder_ca49b1ba": {
+ "en": "Inactivity reminder",
+ "ar": "تذكير الخمول"
+ },
+ "l10n_automations_screen_nap_detection_ca2dedf5": {
+ "en": "Nap detection",
+ "ar": "اكتشاف القيلولة"
+ },
+ "l10n_automations_screen_no_naps_to_review_detected_naps_2e82e9fc": {
+ "en": "No naps to review. Detected naps show up here after a history sync.",
+ "ar": "لا قيلولات للمراجعة. تظهر القيلولات المكتشفة هنا بعد مزامنة السجل."
+ },
+ "l10n_automations_screen_notifications_are_off_so_this_can_b3dba7ee": {
+ "en": "Notifications are off, so this can\\'t buzz yet. Turn on the master switch in ",
+ "ar": "الإشعارات مغلقة، لذا لا يمكن أن يهتز هذا بعد. فعّل المفتاح الرئيسي في "
+ },
+ "l10n_automations_screen_notify_on_low_and_full_battery_d1903bb8": {
+ "en": "Notify on low and full battery",
+ "ar": "تنبيه عند انخفاض البطارية واكتمالها"
+ },
+ "l10n_automations_screen_only_during_active_hours_29c53fc9": {
+ "en": "Only during active hours",
+ "ar": "أثناء ساعات النشاط فقط"
+ },
+ "l10n_automations_screen_per_day_wake_time_873c81e1": {
+ "en": "Per-day wake time",
+ "ar": "وقت الاستيقاظ لكل يوم"
+ },
+ "l10n_automations_screen_predictive_runtime_warning_4d85f5a6": {
+ "en": "Predictive runtime warning",
+ "ar": "تحذير تنبؤي بمدة التشغيل"
+ },
+ "l10n_automations_screen_re_nudge_every_646023cd": {
+ "en": "Re-nudge every",
+ "ar": "إعادة التذكير كل"
+ },
+ "l10n_automations_screen_recovery_buzz_1abc9a51": {
+ "en": "Recovery buzz",
+ "ar": "اهتزاز التعافي"
+ },
+ "l10n_automations_screen_reset_44c57abd": {
+ "en": "Reset",
+ "ar": "إعادة تعيين"
+ },
+ "l10n_automations_screen_sitting_for_e464c472": {
+ "en": "Sitting for",
+ "ar": "الجلوس لمدة"
+ },
+ "l10n_automations_screen_test_action_266df314": {
+ "en": "Test action",
+ "ar": "اختبار الإجراء"
+ },
+ "l10n_automations_screen_watch_for_early_illness_signs_4c22e127": {
+ "en": "Watch for early-illness signs",
+ "ar": "مراقبة علامات المرض المبكرة"
+ },
+ "l10n_automations_screen_when_i_double_tap_a1f1e04d": {
+ "en": "When I double-tap",
+ "ar": "عند النقر المزدوج"
+ },
+ "l10n_backup_sync_screen_auto_backup_hasn_t_run_in_e3cd484e": {
+ "en": "Auto-backup hasn\\'t run in a few days. Check the backup folder is still available — a moved or disconnected cloud folder stops backups silently.",
+ "ar": "لم يعمل النسخ الاحتياطي التلقائي منذ عدة أيام. تأكد من أن مجلد النسخ ما زال متاحًا — فالمجلد السحابي المنقول أو غير المتصل يوقف النسخ بصمت."
+ },
+ "l10n_backup_sync_screen_backup_folder_2a33df93": {
+ "en": "Backup folder",
+ "ar": "مجلد النسخ الاحتياطي"
+ },
+ "l10n_backup_sync_screen_backup_sync_81758ffa": {
+ "en": "Backup & Sync",
+ "ar": "النسخ الاحتياطي والمزامنة"
+ },
+ "l10n_backup_sync_screen_backup_time_81557aaa": {
+ "en": "Backup time",
+ "ar": "وقت النسخ الاحتياطي"
+ },
+ "l10n_backup_sync_screen_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_backup_sync_screen_choose_a_backup_2fbfb0d6": {
+ "en": "Choose a backup",
+ "ar": "اختر نسخة احتياطية"
+ },
+ "l10n_backup_sync_screen_daily_auto_backup_e5627357": {
+ "en": "Daily auto-backup",
+ "ar": "نسخ احتياطي تلقائي يومي"
+ },
+ "l10n_backup_sync_screen_keep_last_snapshots_cd5c9ea9": {
+ "en": "Keep last snapshots",
+ "ar": "الاحتفاظ بآخر النسخ"
+ },
+ "l10n_backup_sync_screen_newest_first_restoring_replaces_this_device_97dbcd8b": {
+ "en": "Newest first. Restoring replaces this device\\'s data.",
+ "ar": "الأحدث أولًا. الاستعادة تستبدل بيانات هذا الجهاز."
+ },
+ "l10n_backup_sync_screen_older_backups_beyond_this_many_are_00b7daa6": {
+ "en": "Older backups beyond this many are pruned, oldest first (≈ that many days of ",
+ "ar": "تُحذف النسخ الأقدم بعد هذا العدد، بدءًا من الأقدم (≈ هذا العدد من الأيام من "
+ },
+ "l10n_backup_sync_screen_replace_a7cf7b25": {
+ "en": "Replace",
+ "ar": "استبدال"
+ },
+ "l10n_backup_sync_screen_replace_all_current_data_e9244799": {
+ "en": "Replace all current data?",
+ "ar": "استبدال كل البيانات الحالية؟"
+ },
+ "l10n_backup_sync_screen_replace_all_current_data_with_label_b7799a16": {
+ "en": "Replace all current data with %1$s? This cannot be undone.",
+ "ar": "استبدال كل البيانات الحالية بـ %1$s؟ لا يمكن التراجع عن ذلك."
+ },
+ "l10n_backup_sync_screen_replace_this_device_s_data_with_b8679c51": {
+ "en": "Replace this device\\'s data with one of your backups. This overwrites current data, ",
+ "ar": "استبدل بيانات هذا الجهاز بإحدى نسخك الاحتياطية. يستبدل هذا البيانات الحالية، "
+ },
+ "l10n_backup_sync_screen_restore_3cbe6d6b": {
+ "en": "Restore",
+ "ar": "استعادة"
+ },
+ "l10n_backup_sync_screen_restore_from_a_backup_c28917c6": {
+ "en": "Restore from a backup…",
+ "ar": "الاستعادة من نسخة احتياطية…"
+ },
+ "l10n_backup_sync_screen_roughly_when_the_daily_backup_runs_71de04e1": {
+ "en": "Roughly when the daily backup runs (best-effort — the system may slide it a little).",
+ "ar": "تقريبًا متى يعمل النسخ اليومي (بأفضل جهد — قد يؤجّله النظام قليلًا)."
+ },
+ "l10n_backup_sync_screen_these_backups_are_unencrypted_too_if_53e70fe8": {
+ "en": "These backups are unencrypted too. If this folder syncs to Drive, Dropbox or iCloud, the readable file goes there as well — only point it at a service you trust.",
+ "ar": "هذه النسخ غير مشفّرة أيضًا. إذا كان هذا المجلد يتزامن مع Drive أو Dropbox أو iCloud، فسينتقل الملف المقروء إلى هناك كذلك — فوجّهه إلى خدمة تثق بها فقط."
+ },
+ "l10n_backup_sync_screen_tip_a_desktop_drive_dropbox_app_2eaff1e3": {
+ "en": "Tip: a desktop Drive / Dropbox app auto-syncs a chosen folder. On the phone, save to a ",
+ "ar": "نصيحة: تطبيق Drive / Dropbox على سطح المكتب يزامن مجلدًا تختاره تلقائيًا. على الهاتف، احفظ في "
+ },
+ "l10n_backup_sync_screen_writes_a_fresh_dated_backup_to_bd964fc5": {
+ "en": "Writes a fresh dated backup to your folder once a day at the time below, keeping ",
+ "ar": "يكتب نسخة احتياطية جديدة مؤرخة في مجلدك مرة يوميًا في الوقت أدناه، مع الاحتفاظ بـ "
+ },
+ "l10n_body_clock_dial_card_1_s_earlier_than_your_body_68877f50": {
+ "en": "%1$s earlier than your body clock",
+ "ar": "أبكر بـ %1$s من ساعتك البيولوجية"
+ },
+ "l10n_body_clock_dial_card_1_s_later_than_your_body_50b7d966": {
+ "en": "%1$s later than your body clock",
+ "ar": "أمتأخر بـ %1$s عن ساعتك البيولوجية"
+ },
+ "l10n_body_clock_dial_card_body_clock_b0b9b988": {
+ "en": "Body clock",
+ "ar": "الساعة البيولوجية"
+ },
+ "l10n_body_clock_dial_card_body_clock_dial_03cdbc31": {
+ "en": "Body clock dial",
+ "ar": "قرص الساعة البيولوجية"
+ },
+ "l10n_body_clock_dial_card_evening_type_3e1ce111": {
+ "en": "Evening type",
+ "ar": "نمط مسائي"
+ },
+ "l10n_body_clock_dial_card_in_sync_with_your_body_clock_4b5181c8": {
+ "en": "In sync with your body clock",
+ "ar": "متوافق مع ساعتك البيولوجية"
+ },
+ "l10n_body_clock_dial_card_intermediate_type_a7b6b74c": {
+ "en": "Intermediate type",
+ "ar": "نمط متوسط"
+ },
+ "l10n_body_clock_dial_card_last_night_6eaba4dd": {
+ "en": "Last night",
+ "ar": "الليلة الماضية"
+ },
+ "l10n_body_clock_dial_card_last_night_against_your_clock_9183a37c": {
+ "en": "Last night against your clock",
+ "ar": "الليلة الماضية مقابل ساعتك"
+ },
+ "l10n_body_clock_dial_card_morning_type_289dd7bf": {
+ "en": "Morning type",
+ "ar": "نمط صباحي"
+ },
+ "l10n_body_clock_dial_card_your_clock_1b91f5ee": {
+ "en": "Your clock",
+ "ar": "ساعتك"
+ },
+ "l10n_breathe_screen_a_relaxation_rhythm_not_cardiac_control_5c2f10a8": {
+ "en": "A relaxation rhythm, not cardiac control. It never paces below a safe rate and you can stop anytime. If your heart rate doesn\\'t settle, we\\'ll say so plainly.",
+ "ar": "إيقاع للاسترخاء، لا تحكّم في القلب. لا يهبط بالإيقاع دون معدل آمن ويمكنك التوقف في أي وقت. وإذا لم يهدأ نبضك، سنقول ذلك بوضوح."
+ },
+ "l10n_breathe_screen_about_pace_o3p4q5r6": {
+ "en": "About this pace",
+ "ar": "عن هذا الإيقاع"
+ },
+ "l10n_breathe_screen_audio_cues_74430aec": {
+ "en": "Audio cues",
+ "ar": "تنبيهات صوتية"
+ },
+ "l10n_breathe_screen_breathcount_breaths_ce036831": {
+ "en": "%1$s breaths",
+ "ar": "%1$s نفَس"
+ },
+ "l10n_breathe_screen_breathe_282be568": {
+ "en": "Breathe",
+ "ar": "التنفس"
+ },
+ "l10n_breathe_screen_breathe_in_a5b6c7d8": {
+ "en": "Breathe in…",
+ "ar": "شهيق…"
+ },
+ "l10n_breathe_screen_breathe_now_98d6c341": {
+ "en": "Breathe now",
+ "ar": "تنفّس الآن"
+ },
+ "l10n_breathe_screen_breathe_out_e9f0a1b2": {
+ "en": "Breathe out…",
+ "ar": "زفير…"
+ },
+ "l10n_breathe_screen_calm_me_3_min_11250c77": {
+ "en": "Calm me · 3 min",
+ "ar": "هدّئني · 3 دقائق"
+ },
+ "l10n_breathe_screen_calm_me_a1b2c3d4": {
+ "en": "Calm me",
+ "ar": "هدّئني"
+ },
+ "l10n_breathe_screen_connect_your_strap_calm_me_is_71314744": {
+ "en": "Connect your strap. Calm me is a felt rhythm on the wrist, so it needs a bonded connection.",
+ "ar": "وصّل سوارك. «هدّئني» إيقاع محسوس على المعصم، لذا يحتاج إلى اتصال مقترن."
+ },
+ "l10n_breathe_screen_connect_your_strap_for_haptic_guidance_65660684": {
+ "en": "Connect your strap for haptic guidance. You\\'ll feel one pulse on the inhale, two on the exhale, so you can breathe with your eyes closed.",
+ "ar": "وصّل سوارك للإرشاد بالاهتزاز. ستشعر بنبضة واحدة عند الشهيق ونبضتين عند الزفير، لتتنفس وعيناك مغمضتان."
+ },
+ "l10n_breathe_screen_disclaimer_w1x2y3z4": {
+ "en": "Estimate only — not medical advice. Stop if you feel unwell.",
+ "ar": "تقدير فقط — ليس نصيحة طبية. توقف إذا شعرت بتوعك."
+ },
+ "l10n_breathe_screen_done_s7t8u9v0": {
+ "en": "Done",
+ "ar": "تم"
+ },
+ "l10n_breathe_screen_estimate_from_ppg_derived_r_r_9c92fbae": {
+ "en": "Estimate from PPG-derived R-R: relaxation guidance, not a clinical reading. Your pace drifts, so we date it and you can re-measure anytime.",
+ "ar": "تقدير من فترات R-R المشتقة من PPG: إرشاد للاسترخاء وليس قراءة سريرية. إيقاعك يتغير، لذا نؤرّخه ويمكنك إعادة القياس متى شئت."
+ },
+ "l10n_breathe_screen_estimate_only_a_higher_rmssd_while_bfd71bb8": {
+ "en": "Estimate only: a higher RMSSD while paced usually means your parasympathetic \"rest\" branch is engaging. It is not a clinical reading; trends over a session matter more than any single number.",
+ "ar": "تقدير فقط: ارتفاع RMSSD أثناء الإيقاع يعني عادةً أن فرعك السمبثاوي المسؤول عن «الراحة» يعمل. ليست قراءة سريرية؛ والاتجاهات عبر الجلسة أهم من أي رقم منفرد."
+ },
+ "l10n_breathe_screen_everyone_has_a_breathing_pace_usually_ad6ea0b4": {
+ "en": "Everyone has a breathing pace (usually between 4.5 and 7 breaths a minute) where the heart\\'s rhythm swings the most with each breath. We pace you through a few candidate paces, measure how your HRV responds, and lock the one that resonates best for you.",
+ "ar": "لكل شخص إيقاع تنفس (عادة بين 4.5 و7 أنفاس في الدقيقة) يتأرجح عنده إيقاع القلب أكثر ما يمكن مع كل نفَس. نمرّ بك عبر عدة إيقاعات مرشّحة، ونقيس استجابة تقلب النبض لديك، ونثبّت الإيقاع الأكثر رنينًا لك."
+ },
+ "l10n_breathe_screen_follow_cue_c3d4e5f6": {
+ "en": "Follow the cue…",
+ "ar": "اتبع الإشارة…"
+ },
+ "l10n_breathe_screen_full_sweep_13_min_08e01d2c": {
+ "en": "Full sweep · ~13 min",
+ "ar": "مسح كامل · ~13 دقيقة"
+ },
+ "l10n_breathe_screen_guided_k9l0m1n2": {
+ "en": "Guided",
+ "ar": "موجّه"
+ },
+ "l10n_breathe_screen_guided_timer_g7h8i9j0": {
+ "en": "Guided timer",
+ "ar": "مؤقّت موجّه"
+ },
+ "l10n_breathe_screen_heart_rate_410aa15c": {
+ "en": "Heart rate",
+ "ar": "معدل النبض"
+ },
+ "l10n_breathe_screen_hold_g5h6i7j8": {
+ "en": "Hold…",
+ "ar": "احبس…"
+ },
+ "l10n_breathe_screen_hrv_rmssd_51014f87": {
+ "en": "HRV (RMSSD)",
+ "ar": "تقلب النبض (RMSSD)"
+ },
+ "l10n_breathe_screen_locked_formatday_datems_paces_drift_re_8ebec15b": {
+ "en": "Locked %1$s · paces drift, re-measure anytime.",
+ "ar": "مثبّت %1$s · الإيقاعات تتغير، أعد القياس متى شئت."
+ },
+ "l10n_breathe_screen_locked_formatday_datems_switch_to_breathe_3efc52c6": {
+ "en": "Locked %1$s. Switch to Breathe to use it, or re-measure above.",
+ "ar": "مثبّت %1$s. انتقل إلى «التنفس» لاستخدامه، أو أعد القياس أعلاه."
+ },
+ "l10n_breathe_screen_locked_pace_o5p6q7r8": {
+ "en": "Your locked pace · %1$.1f br/min",
+ "ar": "إيقاعك المثبّت · %1$.1f نفَس/دقيقة"
+ },
+ "l10n_breathe_screen_not_enough_clean_beat_data_to_865a1260": {
+ "en": "Not enough clean beat data to lock a pace today. Try again rested, sitting still with the strap snug. For now we\\'ll pace you at 5.5 br/min (coherence).",
+ "ar": "لا توجد بيانات نبض نظيفة كافية لتثبيت إيقاع اليوم. حاول مجددًا وأنت مرتاح، جالسًا دون حركة والسوار محكم. وحاليًا سنضبط إيقاعك على 5.5 نفَس/دقيقة (الاتساق)."
+ },
+ "l10n_breathe_screen_not_now_e4571490": {
+ "en": "Not now",
+ "ar": "ليس الآن"
+ },
+ "l10n_breathe_screen_pace_7a9a6226": {
+ "en": "Pace",
+ "ar": "الإيقاع"
+ },
+ "l10n_breathe_screen_protocol_info_c1d2e3f4": {
+ "en": "Protocol info",
+ "ar": "معلومات البروتوكول"
+ },
+ "l10n_breathe_screen_quick_sweep_7_min_5d58f88d": {
+ "en": "Quick sweep · ~7 min",
+ "ar": "مسح سريع · ~7 دقائق"
+ },
+ "l10n_breathe_screen_relaxation_guidance_from_your_own_numbers_16ee0ba1": {
+ "en": "Relaxation guidance from your own numbers: not a health alert, and not a diagnosis. Trends matter more than any single number.",
+ "ar": "إرشاد للاسترخاء من أرقامك أنت: ليس تنبيهًا صحيًا ولا تشخيصًا. الاتجاهات أهم من أي رقم منفرد."
+ },
+ "l10n_breathe_screen_resonance_edu_s9t0u1v2": {
+ "en": "Your locked resonance pace from the Resonance sweep.",
+ "ar": "إيقاع الرنين المثبّت من مسح الرنين."
+ },
+ "l10n_breathe_screen_resonance_k1l2m3n4": {
+ "en": "Resonance",
+ "ar": "الرنين"
+ },
+ "l10n_breathe_screen_session_10_min_a3b4c5d6": {
+ "en": "10 min",
+ "ar": "10 دقائق"
+ },
+ "l10n_breathe_screen_session_15_min_e7f8a9b0": {
+ "en": "15 min",
+ "ar": "15 دقيقة"
+ },
+ "l10n_breathe_screen_session_5_min_c9d0e1f2": {
+ "en": "5 min",
+ "ar": "5 دقائق"
+ },
+ "l10n_breathe_screen_session_length_a1b2c3d4": {
+ "en": "Session length",
+ "ar": "مدة الجلسة"
+ },
+ "l10n_breathe_screen_session_open_e5f6a7b8": {
+ "en": "Open",
+ "ar": "فتح"
+ },
+ "l10n_breathe_screen_sign_kotlin_math_abs_trend_hrv_e9362899": {
+ "en": "%1$s%2$s%% HRV",
+ "ar": "%1$s%2$s%% تقلب النبض"
+ },
+ "l10n_breathe_screen_sit_still_and_breathe_with_the_b7c53c40": {
+ "en": "Sit still and breathe with the buzz. You can stop anytime; a stopped sweep won\\'t lock a pace.",
+ "ar": "اجلس دون حركة وتنفّس مع الاهتزاز. يمكنك التوقف في أي وقت؛ والمسح المتوقف لن يثبّت إيقاعًا."
+ },
+ "l10n_breathe_screen_soft_tone_on_each_phase_honours_2a02c284": {
+ "en": "Soft tone on each phase · honours silent mode",
+ "ar": "نغمة خفيفة عند كل مرحلة · تحترم الوضع الصامت"
+ },
+ "l10n_breathe_screen_started_at_it_bpm_the_rhythm_dda6a573": {
+ "en": "Started at %1$s bpm · the rhythm trails your heart down.",
+ "ar": "بدأنا عند %1$s نبضة/دقيقة · الإيقاع يتبع هبوط قلبك."
+ },
+ "l10n_breathe_screen_stop_9e253470": {
+ "en": "Stop",
+ "ar": "إيقاف"
+ },
+ "l10n_breathe_screen_stop_sweep_55299cf9": {
+ "en": "Stop sweep",
+ "ar": "إيقاف المسح"
+ },
+ "l10n_breathe_screen_test_buzz_deeab5ae": {
+ "en": "Test buzz",
+ "ar": "اختبار الاهتزاز"
+ },
+ "l10n_breathe_screen_that_s_normal_a_paced_breath_72e2d011": {
+ "en": "That\\'s normal. A paced breath often settles things when a metronome alone doesn\\'t.",
+ "ar": "هذا طبيعي. النفَس المنتظم غالبًا ما يهدّئ حين لا يفلح المترونوم وحده."
+ },
+ "l10n_breathe_screen_the_strap_buzzes_a_gentle_rhythm_eb5c6a65": {
+ "en": "The strap buzzes a gentle rhythm just below your current heart rate, a felt metronome to relax toward. It trails your heart down rather than yanking it, and stops on its own.",
+ "ar": "يهتز السوار بإيقاع لطيف أقل قليلًا من معدل نبضك الحالي، كمترونوم محسوس تسترخي نحوه. يتبع هبوط قلبك بدل أن يجذبه، ويتوقف من تلقاء نفسه."
+ },
+ "l10n_breathe_screen_turn_off_8807c2b3": {
+ "en": "Turn off",
+ "ar": "إيقاف"
+ },
+ "l10n_breathe_screen_waiting_for_a_resting_heart_rate_980cedbf": {
+ "en": "Waiting for a resting heart rate. Start a live reading first, or come back when you\\'re still.",
+ "ar": "في انتظار معدل نبض في الراحة. ابدأ قراءة مباشرة أولًا، أو عد حين تكون ساكنًا."
+ },
+ "l10n_breathe_screen_your_hrv_dipped_while_you_were_231d3c7a": {
+ "en": "Your HRV dipped while you were still. Want a minute to breathe?",
+ "ar": "انخفض تقلب نبضك بينما كنت ساكنًا. أتريد دقيقة للتنفس؟"
+ },
+ "l10n_caffeine_log_after_your_clocklabel_cutoffminutes_cutoff_may_3c023900": {
+ "en": "After your %1$s cutoff - may still be active at bed.",
+ "ar": "بعد حدّك %1$s — قد يظل فعّالًا عند النوم."
+ },
+ "l10n_caffeine_log_amount_in_mg_optional_f0749888": {
+ "en": "Amount in mg (optional)",
+ "ar": "الكمية بالمليغرام (اختياري)"
+ },
+ "l10n_caffeine_log_bedtime_e3cb8bd6": {
+ "en": "Bedtime",
+ "ar": "وقت النوم"
+ },
+ "l10n_caffeine_log_caffeine_22859fa3": {
+ "en": "Caffeine",
+ "ar": "الكافيين"
+ },
+ "l10n_caffeine_log_flag_drinks_late_enough_to_still_af311272": {
+ "en": "Flag drinks late enough to still be active at bedtime.",
+ "ar": "تمييز المشروبات المتأخرة بما يكفي لتظل فعّالة عند النوم."
+ },
+ "l10n_caffeine_log_had_it_8576ac66": {
+ "en": "Had it",
+ "ar": "تناولته"
+ },
+ "l10n_caffeine_log_have_your_last_caffeine_by_about_16b09033": {
+ "en": "Have your last caffeine by about %1$s to clear most of it ",
+ "ar": "تناول آخر كافيين قبل %1$s تقريبًا ليزول معظمه "
+ },
+ "l10n_caffeine_log_late_caffeine_nudge_6b2ff690": {
+ "en": "Late-caffeine nudge",
+ "ar": "تنبيه الكافيين المتأخر"
+ },
+ "l10n_caffeine_log_log_a_coffee_tea_or_energy_982bde5b": {
+ "en": "Log a coffee, tea, or energy drink and NOOP shows a rough estimate of how much may still be active. It\\'s a guide based on a typical 5 to 6 hour half-life, not a measurement.",
+ "ar": "سجّل قهوة أو شايًا أو مشروب طاقة، ويعرض NOOP تقديرًا تقريبيًا لما قد يظل فعّالًا. إنه دليل مبني على عمر نصف نموذجي من 5 إلى 6 ساعات، وليس قياسًا."
+ },
+ "l10n_caffeine_log_logged_today_0071a46c": {
+ "en": "Logged today",
+ "ar": "سُجِّل اليوم"
+ },
+ "l10n_caffeine_log_remove_e963907d": {
+ "en": "Remove",
+ "ar": "إزالة"
+ },
+ "l10n_coach_screen_api_key_hidden_f3cde531": {
+ "en": "API key (hidden)",
+ "ar": "مفتاح API (مخفي)"
+ },
+ "l10n_coach_screen_ask_anything_about_your_recent_recovery_e6c287ca": {
+ "en": "Ask anything about your recent recovery, strain, sleep or HRV.",
+ "ar": "اسأل عن أي شيء يخص تعافيك أو إجهادك أو نومك أو تقلب نبضك مؤخرًا."
+ },
+ "l10n_coach_screen_ask_your_coach_b1577d4c": {
+ "en": "Ask your coach…",
+ "ar": "اسأل مدربك…"
+ },
+ "l10n_coach_screen_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_coach_screen_coach_b32c9ad3": {
+ "en": "Coach",
+ "ar": "المدرب الذكي"
+ },
+ "l10n_coach_screen_coach_error_error_ad9c8c46": {
+ "en": "Coach error: %1$s",
+ "ar": "خطأ في المدرب الذكي: %1$s"
+ },
+ "l10n_coach_screen_coach_instructions_28a07975": {
+ "en": "Coach instructions",
+ "ar": "تعليمات المدرب الذكي"
+ },
+ "l10n_coach_screen_coach_instructions_editor_b8f3ad31": {
+ "en": "Coach instructions editor",
+ "ar": "محرّر تعليمات المدرب الذكي"
+ },
+ "l10n_coach_screen_coach_is_thinking_aaf91547": {
+ "en": "Coach is thinking",
+ "ar": "المدرب الذكي يفكر"
+ },
+ "l10n_coach_screen_connect_a_provider_6967f288": {
+ "en": "Connect a provider",
+ "ar": "وصّل مزوّدًا"
+ },
+ "l10n_coach_screen_connect_b65463cb": {
+ "en": "Connect",
+ "ar": "توصيل"
+ },
+ "l10n_coach_screen_custom_dce04fd3": {
+ "en": "Custom…",
+ "ar": "مخصص…"
+ },
+ "l10n_coach_screen_custom_model_2e3bedea": {
+ "en": "Custom model",
+ "ar": "نموذج مخصص"
+ },
+ "l10n_coach_screen_custom_model_id_6ffe2740": {
+ "en": "Custom model id",
+ "ar": "معرّف نموذج مخصص"
+ },
+ "l10n_coach_screen_disconnect_ed28e068": {
+ "en": "Disconnect",
+ "ar": "قطع الاتصال"
+ },
+ "l10n_coach_screen_disconnect_provider_fa13625c": {
+ "en": "Disconnect provider",
+ "ar": "قطع الاتصال بالمزوّد"
+ },
+ "l10n_coach_screen_e_g_gpt_4o_1da2e4d2": {
+ "en": "e.g. gpt-4o",
+ "ar": "مثال: gpt-4o"
+ },
+ "l10n_coach_screen_enter_any_model_id_the_provider_dce4bbcb": {
+ "en": "Enter any model id the provider accepts.",
+ "ar": "أدخل أي معرّف نموذج يقبله المزوّد."
+ },
+ "l10n_coach_screen_fetch_models_from_provider_6654e1a0": {
+ "en": "Fetch models from provider",
+ "ar": "جلب النماذج من المزوّد"
+ },
+ "l10n_coach_screen_key_header_3f2a9b10": {
+ "en": "Key header",
+ "ar": "ترويسة المفتاح"
+ },
+ "l10n_coach_screen_let_the_coach_use_my_data_405d1188": {
+ "en": "Let the coach use my data",
+ "ar": "السماح للمدرب باستخدام بياناتي"
+ },
+ "l10n_coach_screen_model_selected_tap_to_change_043056c1": {
+ "en": "Model: %1$s. Tap to change.",
+ "ar": "النموذج: %1$s. اضغط للتغيير."
+ },
+ "l10n_coach_screen_reset_to_default_39c90eb7": {
+ "en": "Reset to default",
+ "ar": "إعادة إلى الافتراضي"
+ },
+ "l10n_coach_screen_save_key_f5216b3a": {
+ "en": "Save key",
+ "ar": "حفظ المفتاح"
+ },
+ "l10n_coach_screen_send_message_c70a890d": {
+ "en": "Send message",
+ "ar": "إرسال رسالة"
+ },
+ "l10n_coach_screen_server_url_1d5d1eff": {
+ "en": "Server URL",
+ "ar": "عنوان الخادم"
+ },
+ "l10n_coach_screen_suggested_prompt_prompt_379c0b15": {
+ "en": "Suggested prompt: %1$s",
+ "ar": "طلب مقترح: %1$s"
+ },
+ "l10n_coach_screen_thinking_a60d9c9c": {
+ "en": "Thinking…",
+ "ar": "يفكر…"
+ },
+ "l10n_coach_screen_use_bearer_for_most_local_servers_4429ab64": {
+ "en": "Use Bearer for most local servers; use x-api-key for gateways that require the key in that header.",
+ "ar": "استخدم Bearer لمعظم الخوادم المحلية؛ واستخدم x-api-key للبوابات التي تتطلب المفتاح في تلك الترويسة."
+ },
+ "l10n_coach_screen_use_model_8d558ce2": {
+ "en": "Use model",
+ "ar": "استخدام النموذج"
+ },
+ "l10n_compare_screen_add_a_metric_to_compare_e6a7c9f1": {
+ "en": "Add a metric to compare",
+ "ar": "أضف مقياسًا للمقارنة"
+ },
+ "l10n_compare_screen_compare_8d105cf4": {
+ "en": "Compare",
+ "ar": "مقارنة"
+ },
+ "l10n_compare_screen_compare_needs_at_least_two_metrics_2bfe1fad": {
+ "en": "Compare needs at least two metrics with history",
+ "ar": "المقارنة تحتاج إلى مقياسين على الأقل لهما سجل"
+ },
+ "l10n_compare_screen_not_enough_overlapping_days_between_these_44563110": {
+ "en": "Not enough overlapping days between these metrics in %1$s. Widen the range.",
+ "ar": "لا توجد أيام متداخلة كافية بين هذه المقاييس في %1$s. وسّع النطاق."
+ },
+ "l10n_compare_screen_nothing_selected_yet_60968db6": {
+ "en": "Nothing selected yet.",
+ "ar": "لم يُختر شيء بعد."
+ },
+ "l10n_compare_screen_p_n_overlapping_days_strengthword_p_8a8f56c7": {
+ "en": "%1$s overlapping days · %2$s %3$s correlation",
+ "ar": "%1$s يوم متداخل · ارتباط %2$s %3$s"
+ },
+ "l10n_compare_screen_remove_title_ddb5361c": {
+ "en": "Remove %1$s",
+ "ar": "إزالة %1$s"
+ },
+ "l10n_components_chunks_chunks_pulled_cec186cf": {
+ "en": "%1$s chunks pulled",
+ "ar": "تم سحب %1$s جزء"
+ },
+ "l10n_components_decrease_accessibility_df5f1511": {
+ "en": "Decrease %1$s",
+ "ar": "إنقاص %1$s"
+ },
+ "l10n_components_increase_accessibility_0949c0e9": {
+ "en": "Increase %1$s",
+ "ar": "زيادة %1$s"
+ },
+ "l10n_connection_help_allow_nearby_devices_f7c74880": {
+ "en": "Allow Nearby devices",
+ "ar": "السماح بالأجهزة القريبة"
+ },
+ "l10n_connection_help_charge_it_and_put_it_on_31b04814": {
+ "en": "Charge it and put it on",
+ "ar": "اشحنه وارتدِه"
+ },
+ "l10n_connection_help_close_the_official_whoop_app_768d7d17": {
+ "en": "Close the official WHOOP app",
+ "ar": "أغلق تطبيق WHOOP الرسمي"
+ },
+ "l10n_connection_help_try_connecting_now_41769900": {
+ "en": "Try connecting now",
+ "ar": "جرّب الاتصال الآن"
+ },
+ "l10n_connection_help_turn_bluetooth_on_75868e97": {
+ "en": "Turn Bluetooth on",
+ "ar": "شغّل Bluetooth"
+ },
+ "l10n_connection_help_whoop_5_mg_experimental_109076ae": {
+ "en": "WHOOP 5 / MG (experimental)",
+ "ar": "WHOOP 5 / MG (تجريبي)"
+ },
+ "l10n_connection_help_won_t_connect_run_through_these_cfef82f3": {
+ "en": "Won\\'t connect? Run through these",
+ "ar": "لا يتصل؟ راجع هذه الخطوات"
+ },
+ "l10n_connection_help_your_strap_is_connected_and_we_8ab98ac6": {
+ "en": "Your strap is connected and we\\'re trying an experimental handshake to bring up live ",
+ "ar": "سوارك متصل ونحن نجرّب مصافحة تجريبية لتشغيل البث المباشر "
+ },
+ "l10n_coupled_screen_calibrating_calibrationnights_of_baselines_minnightsseed_nights_1a7e8f8e": {
+ "en": "Calibrating, %1$s of %2$s nights",
+ "ar": "جارٍ المعايرة، %1$s من %2$s ليلة"
+ },
+ "l10n_coupled_screen_day_987b9ced": {
+ "en": "Day",
+ "ar": "اليوم"
+ },
+ "l10n_coupled_screen_hoursminutes_asleepmin_slept_732a5b21": {
+ "en": "%1$s slept",
+ "ar": "%1$s نوم"
+ },
+ "l10n_coupled_screen_hoursminutes_needmin_needed_7fc1eb15": {
+ "en": "%1$s needed",
+ "ar": "%1$s مطلوب"
+ },
+ "l10n_coupled_screen_no_effort_yet_f622f99d": {
+ "en": "No effort yet",
+ "ar": "لا جهد بعد"
+ },
+ "l10n_coupled_screen_no_sleep_tracked_last_night_cd0dd79e": {
+ "en": "No sleep tracked last night",
+ "ar": "لم يُسجَّل نوم الليلة الماضية"
+ },
+ "l10n_coupled_screen_recovery_b668d988": {
+ "en": "RECOVERY",
+ "ar": "التعافي"
+ },
+ "l10n_coupled_screen_sleep_performance_4611539f": {
+ "en": "SLEEP PERFORMANCE",
+ "ar": "أداء النوم"
+ },
+ "l10n_data_sources_screen_acts_as_a_standard_bluetooth_heart_f8d13439": {
+ "en": "Acts as a standard Bluetooth heart-rate strap. Pair NOOP from your treadmill, bike or app to see your strap\\'s heart rate there.",
+ "ar": "يعمل كسوار نبض Bluetooth قياسي. اقترن بـ NOOP من جهاز المشي أو الدراجة أو التطبيق لرؤية نبض سوارك هناك."
+ },
+ "l10n_data_sources_screen_auto_sync_health_connect_periodically_6f3f4d92": {
+ "en": "Auto-sync Health Connect periodically",
+ "ar": "مزامنة Health Connect تلقائيًا بشكل دوري"
+ },
+ "l10n_data_sources_screen_auto_sync_periodically_5f3041e8": {
+ "en": "Auto-sync periodically",
+ "ar": "مزامنة تلقائية دورية"
+ },
+ "l10n_data_sources_screen_broadcast_heart_rate_as_a_bluetooth_6a44fdb4": {
+ "en": "Broadcast heart rate as a Bluetooth sensor",
+ "ar": "بثّ معدل النبض كمستشعر Bluetooth"
+ },
+ "l10n_data_sources_screen_broadcast_hr_from_this_phone_10e5605c": {
+ "en": "Broadcast HR from this phone",
+ "ar": "بثّ النبض من هذا الهاتف"
+ },
+ "l10n_data_sources_screen_broadcast_hr_is_on_your_strap_0ad5368a": {
+ "en": "Broadcast HR is ON. This phone is advertising heart rate continuously, which keeps its Bluetooth radio active and drains the battery faster. Turn it off when you\\'re not using it with another device.",
+ "ar": "بثّ النبض مُفعَّل. يبثّ هذا الهاتف معدل النبض باستمرار، مما يبقي راديو Bluetooth نشطًا ويستنزف البطارية أسرع. أوقفه حين لا تستخدمه مع جهاز آخر."
+ },
+ "l10n_data_sources_screen_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_data_sources_screen_data_sources_5e43d6bb": {
+ "en": "Data Sources",
+ "ar": "مصادر البيانات"
+ },
+ "l10n_data_sources_screen_every_3560d90b": {
+ "en": "Every",
+ "ar": "كل"
+ },
+ "l10n_data_sources_screen_import_apple_health_export_533fca27": {
+ "en": "Import Apple Health export…",
+ "ar": "استيراد ملف تصدير Apple Health…"
+ },
+ "l10n_data_sources_screen_import_from_health_connect_35d55e21": {
+ "en": "Import from Health Connect",
+ "ar": "الاستيراد من Health Connect"
+ },
+ "l10n_data_sources_screen_import_lifting_log_8fac7b68": {
+ "en": "Import lifting log…",
+ "ar": "استيراد سجل تمارين القوة…"
+ },
+ "l10n_data_sources_screen_import_mi_band_export_e587801c": {
+ "en": "Import Mi Band export…",
+ "ar": "استيراد ملف تصدير Mi Band…"
+ },
+ "l10n_data_sources_screen_import_nutrition_csv_2c748273": {
+ "en": "Import nutrition CSV…",
+ "ar": "استيراد تغذية CSV…"
+ },
+ "l10n_data_sources_screen_import_wearable_export_0545c430": {
+ "en": "Import wearable export…",
+ "ar": "استيراد ملف تصدير جهاز ملبوس…"
+ },
+ "l10n_data_sources_screen_import_whoop_export_zip_16f4176b": {
+ "en": "Import WHOOP export (.zip)",
+ "ar": "استيراد ملف تصدير WHOOP (.zip)"
+ },
+ "l10n_data_sources_screen_import_workout_file_a3a28e06": {
+ "en": "Import workout file…",
+ "ar": "استيراد ملف تمرين…"
+ },
+ "l10n_data_sources_screen_imported_434eb26f": {
+ "en": "Imported",
+ "ar": "مستورد"
+ },
+ "l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0": {
+ "en": "Last share didn’t finish — NOOP will retry automatically.",
+ "ar": "لم تكتمل آخر مشاركة — سيعيد NOOP المحاولة تلقائيًا."
+ },
+ "l10n_data_sources_screen_last_shared_6bf8389c": {
+ "en": "\"Last shared \"",
+ "ar": "\"آخر مشاركة \""
+ },
+ "l10n_data_sources_screen_last_sync_b793ffab": {
+ "en": "Last sync: ",
+ "ar": "آخر مزامنة: "
+ },
+ "l10n_data_sources_screen_lifting_log_hevy_liftosaur_11df48df": {
+ "en": "Lifting log (Hevy / Liftosaur)",
+ "ar": "سجل تمارين القوة (Hevy / Liftosaur)"
+ },
+ "l10n_data_sources_screen_nutrition_csv_1c1315d9": {
+ "en": "Nutrition (CSV)",
+ "ar": "التغذية (CSV)"
+ },
+ "l10n_data_sources_screen_off_e3de5ab0": {
+ "en": "Off",
+ "ar": "مغلق"
+ },
+ "l10n_data_sources_screen_oura_fitbit_garmin_export_7b21682f": {
+ "en": "Oura / Fitbit / Garmin export",
+ "ar": "تصدير Oura / Fitbit / Garmin"
+ },
+ "l10n_data_sources_screen_re_pull_new_health_connect_data_3e9c3914": {
+ "en": "Re-pull new Health Connect data (e.g. Samsung Health → Health Connect) each ",
+ "ar": "إعادة سحب بيانات Health Connect الجديدة (مثل Samsung Health ← Health Connect) كل "
+ },
+ "l10n_data_sources_screen_remove_apple_health_imported_data_5f878502": {
+ "en": "Remove Apple Health imported data?",
+ "ar": "إزالة البيانات المستوردة من Apple Health؟"
+ },
+ "l10n_data_sources_screen_remove_e963907d": {
+ "en": "Remove",
+ "ar": "إزالة"
+ },
+ "l10n_data_sources_screen_remove_imported_data_813e4695": {
+ "en": "Remove imported data",
+ "ar": "إزالة البيانات المستوردة"
+ },
+ "l10n_data_sources_screen_share_back_to_health_connect_1d578f4a": {
+ "en": "Share back to Health Connect",
+ "ar": "المشاركة العكسية إلى Health Connect"
+ },
+ "l10n_data_sources_screen_share_computed_metrics_back_to_health_c11f5d70": {
+ "en": "Share computed metrics back to Health Connect",
+ "ar": "مشاركة المقاييس المحتسبة عكسيًا إلى Health Connect"
+ },
+ "l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315": {
+ "en": "Sharing paused — Health Connect permission was revoked. Tap to re-grant.",
+ "ar": "تم إيقاف المشاركة مؤقتًا — سُحب إذن Health Connect. اضغط لمنحه مجددًا."
+ },
+ "l10n_data_sources_screen_this_permanently_deletes_everything_imported_from_f42e760e": {
+ "en": "This permanently deletes everything imported from Apple Health: heart rate, HRV, sleep, steps, workouts and more. Your live strap data is untouched. This can\\'t be undone.",
+ "ar": "يحذف هذا نهائيًا كل ما استُورد من Apple Health: معدل النبض وتقلب النبض والنوم والخطوات والتمارين وغيرها. لا تتأثر بيانات سوارك المباشرة. لا يمكن التراجع عن ذلك."
+ },
+ "l10n_data_sources_screen_whoop_history_db101974": {
+ "en": "WHOOP History",
+ "ar": "سجل WHOOP"
+ },
+ "l10n_data_sources_screen_whoop_strap_live_ble_217f7df6": {
+ "en": "WHOOP Strap (Live BLE)",
+ "ar": "سوار WHOOP (BLE مباشر)"
+ },
+ "l10n_data_sources_screen_workout_file_gpx_tcx_fit_5469c068": {
+ "en": "Workout file (GPX / TCX / FIT)",
+ "ar": "ملف تمرين (GPX / TCX / FIT)"
+ },
+ "l10n_data_sources_screen_write_the_metrics_noop_computes_from_439940c2": {
+ "en": "Write the metrics NOOP computes from your strap (resting HR, HRV, SpO₂, ",
+ "ar": "كتابة المقاييس التي يحتسبها NOOP من سوارك (نبض الراحة، وتقلب النبض، وSpO₂، "
+ },
+ "l10n_devices_screen_add_a_device_f90866b8": {
+ "en": "Add a device",
+ "ar": "إضافة جهاز"
+ },
+ "l10n_devices_screen_battery_4a9be042": {
+ "en": "Battery",
+ "ar": "البطارية"
+ },
+ "l10n_devices_screen_battery_clamped_2494c8c9": {
+ "en": "Battery %1$s%%",
+ "ar": "البطارية %1$s%%"
+ },
+ "l10n_devices_screen_battery_info_probe_592_re_1dbd4c0f": {
+ "en": "Battery-info probe (#592 RE)…",
+ "ar": "فحص معلومات البطارية (#592 RE)…"
+ },
+ "l10n_devices_screen_battery_info_probe_result_592_b97c0bb8": {
+ "en": "Battery-info probe result (#592)",
+ "ar": "نتيجة فحص البطارية (#592)"
+ },
+ "l10n_devices_screen_battery_probe_explainer_2858bb6a": {
+ "en": "Two independent protocol tables disagree on the extended-battery opcode (98 vs 87). This sends the curated read-only 98 and dumps the strap\\'s full raw reply to the strap log. A battery-style payload confirms 98 on your firmware; a short stub means it stays ambiguous. Nothing is written to the strap. Please export and share the strap log afterwards.",
+ "ar": "تختلف جدولان مستقلان للبروتوكول حول رمز البطارية الممتد (98 مقابل 87). يرسل هذا الأمر 98 للقراءة فقط ويكتب رد السوار الخام الكامل في سجل السوار. الرد بصيغة بطارية يؤكد 98 على برنامجك الثابت؛ والرد المختصر يعني بقاء الأمر ملتبسًا. لا يُكتب شيء على السوار. يُرجى تصدير سجل السوار ومشاركته بعد ذلك."
+ },
+ "l10n_devices_screen_body_location_probe_690_re_7def8c39": {
+ "en": "Body-location probe (#690 RE)…",
+ "ar": "فحص موضع الجسم (#690 RE)…"
+ },
+ "l10n_devices_screen_body_location_probe_explainer_a9363239": {
+ "en": "Sends the read-only body-location command (0x54) and dumps the strap\\'s full raw reply to the strap log, decoding the record (revision / location / confidence / status) on WHOOP 4.0. Nothing is written to the strap, and it never changes wear detection or scoring.",
+ "ar": "يرسل أمر موضع الجسم للقراءة فقط (0x54) ويكتب رد السوار الخام الكامل في السجل، مع فكّ ترميز السجل (المراجعة / الموضع / الثقة / الحالة) على WHOOP 4.0. لا يُكتب شيء على السوار، ولا يغيّر أبدًا كشف الارتداء أو الدرجات."
+ },
+ "l10n_devices_screen_body_location_probe_result_690_60c5ee79": {
+ "en": "Body-location probe result (#690)",
+ "ar": "نتيجة فحص موضع الجسم (#690)"
+ },
+ "l10n_devices_screen_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_devices_screen_close_bbfa773e": {
+ "en": "Close",
+ "ar": "إغلاق"
+ },
+ "l10n_devices_screen_copy_af74f7c5": {
+ "en": "Copy",
+ "ar": "نسخ"
+ },
+ "l10n_devices_screen_delete_all_of_this_device_s_754cde90": {
+ "en": "Delete all of this device\\'s data?",
+ "ar": "حذف كل بيانات هذا الجهاز؟"
+ },
+ "l10n_devices_screen_delete_this_device_s_data_3cae7a2a": {
+ "en": "Delete this device\\'s data…",
+ "ar": "حذف بيانات هذا الجهاز…"
+ },
+ "l10n_devices_screen_device_actions_for_displayname_device_160eb3de": {
+ "en": "Device actions for %1$s",
+ "ar": "إجراءات الجهاز لـ %1$s"
+ },
+ "l10n_devices_screen_device_config_probe_explainer_dd23169f": {
+ "en": "Asks the strap for config VALUES: GET_DEVICE_CONFIG_VALUE (0x79) and GET_FF_VALUE (0x80), one key per round-trip. Both commands may simply not exist in this firmware — finding that out is the point. Read-only — no value is written, and the SET commands are never sent from this probe. The result is shown here and written to the strap log.",
+ "ar": "يطلب من السوار قيم الإعدادات: GET_DEVICE_CONFIG_VALUE (0x79) وGET_FF_VALUE (0x80)، مفتاح واحد لكل دورة. قد لا يكون الأمران موجودين أصلًا في هذا البرنامج الثابت — ومعرفة ذلك هي الهدف. للقراءة فقط — لا تُكتب أي قيمة، ولا تُرسل أوامر الضبط أبدًا من هذا الفحص. تُعرض النتيجة هنا وتُكتب في سجل السوار."
+ },
+ "l10n_devices_screen_device_config_read_probe_103_re_837f46de": {
+ "en": "Device-config read probe (#103 RE)…",
+ "ar": "فحص قراءة إعدادات الجهاز (#103 RE)…"
+ },
+ "l10n_devices_screen_device_config_read_probe_result_103_67d02ec9": {
+ "en": "Device-config read probe result (#103)",
+ "ar": "نتيجة فحص قراءة إعدادات الجهاز (#103)"
+ },
+ "l10n_devices_screen_device_name_79d7a157": {
+ "en": "Device name",
+ "ar": "اسم الجهاز"
+ },
+ "l10n_devices_screen_devices_df485c87": {
+ "en": "Devices",
+ "ar": "الأجهزة"
+ },
+ "l10n_devices_screen_disconnect_ed28e068": {
+ "en": "Disconnect",
+ "ar": "قطع الاتصال"
+ },
+ "l10n_devices_screen_feature_flag_probe_761_re_21241d68": {
+ "en": "Feature-flag probe (#761 RE)…",
+ "ar": "فحص أعلام الميزات (#761 RE)…"
+ },
+ "l10n_devices_screen_feature_flag_probe_explainer_58eec30f": {
+ "en": "Asks the strap to list the feature-flag NAMES its own firmware knows: START_FF_KEY_EXCHANGE (0x75) then SEND_NEXT_FF (0x76) until the strap says it\\'s done. Read-only — no flag value is written, and the SET commands are never sent from this probe. The list is shown here and written to the strap log.",
+ "ar": "يطلب من السوار سرد أسماء أعلام الميزات التي يعرفها برنامجه الثابت: START_FF_KEY_EXCHANGE (0x75) ثم SEND_NEXT_FF (0x76) حتى يعلن السوار الانتهاء. للقراءة فقط — لا تُكتب أي قيمة علم، ولا تُرسل أوامر الضبط أبدًا من هذا الفحص. تُعرض القائمة هنا وتُكتب في سجل السوار."
+ },
+ "l10n_devices_screen_feature_flag_probe_result_761_c50ef4d4": {
+ "en": "Feature-flag probe result (#761)",
+ "ar": "نتيجة فحص أعلام الميزات (#761)"
+ },
+ "l10n_devices_screen_forget_device_926e503d": {
+ "en": "Forget device",
+ "ar": "نسيان الجهاز"
+ },
+ "l10n_devices_screen_forget_device_d6eb6209": {
+ "en": "Forget device…",
+ "ar": "نسيان الجهاز…"
+ },
+ "l10n_devices_screen_forget_this_device_8dbbc3f3": {
+ "en": "Forget this device?",
+ "ar": "نسيان هذا الجهاز؟"
+ },
+ "l10n_devices_screen_getting_your_devices_ready_bd391949": {
+ "en": "Getting your devices ready",
+ "ar": "جارٍ تجهيز أجهزتك"
+ },
+ "l10n_devices_screen_give_device_brand_device_model_a_7a15585c": {
+ "en": "Give %1$s %2$s a name you\\'ll recognise.",
+ "ar": "امنح %1$s %2$s اسمًا تتعرف عليه."
+ },
+ "l10n_devices_screen_leave_none_active_b5c858cb": {
+ "en": "Leave none active",
+ "ar": "عدم تفعيل أي جهاز"
+ },
+ "l10n_devices_screen_make_active_75690bb8": {
+ "en": "Make active",
+ "ar": "تعيينه نشطًا"
+ },
+ "l10n_devices_screen_make_this_your_active_strap_fea6bebd": {
+ "en": "Make this your active strap?",
+ "ar": "تعيين هذا سوارك النشط؟"
+ },
+ "l10n_devices_screen_name_709a2322": {
+ "en": "Name",
+ "ar": "الاسم"
+ },
+ "l10n_devices_screen_noop_removes_this_device_503aff9e": {
+ "en": "NOOP removes this device from your list and deletes its recorded data here. You can re-pair the strap to pull its recent history back.",
+ "ar": "يزيل NOOP هذا الجهاز من قائمتك ويحذف بياناته المسجّلة هنا. يمكنك إعادة اقتران السوار لسحب سجله الأخير مرة أخرى."
+ },
+ "l10n_devices_screen_pack_voltage_9af3c3ff": {
+ "en": "%1$.2f V",
+ "ar": "%1$.2f فولت"
+ },
+ "l10n_devices_screen_paired_locally_noop_owns_this_ring_30c16190": {
+ "en": "Paired locally. NOOP owns this ring while it holds the key. If you reset it again or set it up in the Oura app, NOOP no longer owns it and you would re-add it to take it over.",
+ "ar": "مقترن محليًا. يملك NOOP هذا الخاتم ما دام يحتفظ بالمفتاح. وإذا أعدت ضبطه أو أعددته في تطبيق Oura، فلن يعود NOOP مالكًا له وستحتاج إلى إعادة إضافته للاستحواذ عليه."
+ },
+ "l10n_devices_screen_pick_a_new_active_strap_edd73542": {
+ "en": "Pick a new active strap",
+ "ar": "اختر سوارًا نشطًا جديدًا"
+ },
+ "l10n_devices_screen_reboot_probe_4_0_re_828b3916": {
+ "en": "Reboot probe (4.0 RE)…",
+ "ar": "فحص إعادة التشغيل (4.0 RE)…"
+ },
+ "l10n_devices_screen_reconnect_6988b16a": {
+ "en": "Reconnect",
+ "ar": "إعادة الاتصال"
+ },
+ "l10n_devices_screen_remove_e963907d": {
+ "en": "Remove",
+ "ar": "إزالة"
+ },
+ "l10n_devices_screen_remove_this_device_dd9dbda9": {
+ "en": "Remove this device?",
+ "ar": "إزالة هذا الجهاز؟"
+ },
+ "l10n_devices_screen_rename_d3f4cb89": {
+ "en": "Rename",
+ "ar": "إعادة تسمية"
+ },
+ "l10n_devices_screen_rename_device_ee233604": {
+ "en": "Rename device",
+ "ar": "إعادة تسمية الجهاز"
+ },
+ "l10n_devices_screen_restart_strap_c976cd6c": {
+ "en": "Restart strap…",
+ "ar": "إعادة تشغيل السوار…"
+ },
+ "l10n_devices_screen_restart_this_strap_50fc481b": {
+ "en": "Restart this strap?",
+ "ar": "إعادة تشغيل هذا السوار؟"
+ },
+ "l10n_devices_screen_save_efc007a3": {
+ "en": "Save",
+ "ar": "حفظ"
+ },
+ "l10n_devices_screen_send_probe_read_only_36b318bc": {
+ "en": "Send probe (read-only)",
+ "ar": "إرسال الفحص (للقراءة فقط)"
+ },
+ "l10n_devices_screen_stop_sync_4a1f2b6e": {
+ "en": "Stop sync",
+ "ar": "إيقاف المزامنة"
+ },
+ "l10n_devices_screen_the_whoop_4_0_reboot_frame_690a8ff2": {
+ "en": "The WHOOP 4.0 reboot frame isn\\'t confirmed — a normal Restart is ignored (#235). ",
+ "ar": "إطار إعادة تشغيل WHOOP 4.0 غير مؤكد — وإعادة التشغيل العادية تُتجاهل (#235). "
+ },
+ "l10n_devices_screen_waiting_for_the_straps_reply_5a06e7ac": {
+ "en": "Waiting for the strap\\'s reply…",
+ "ar": "في انتظار رد السوار…"
+ },
+ "l10n_devices_screen_whoop_4_0_reboot_probe_b51fb50f": {
+ "en": "WHOOP 4.0 reboot probe",
+ "ar": "فحص إعادة تشغيل WHOOP 4.0"
+ },
+ "l10n_devices_screen_whoop_is_noop_s_primary_fully_1c9e67fd": {
+ "en": "WHOOP is NOOP\\'s primary, fully-supported band. Other heart-rate straps are an early, ",
+ "ar": "WHOOP هو السوار الأساسي المدعوم بالكامل في NOOP. أساور النبض الأخرى في مرحلة مبكرة، "
+ },
+ "l10n_devices_screen_you_removed_your_active_strap_choose_2ac91d48": {
+ "en": "You removed your active strap. Choose which paired band provides your live data, or leave none active and pair one later.",
+ "ar": "لقد أزلت سوارك النشط. اختر السوار المقترن الذي يوفّر بياناتك المباشرة، أو اترك الجميع غير نشط واقترن بواحد لاحقًا."
+ },
+ "l10n_fused_record_screen_done_e9b450d1": {
+ "en": "Done",
+ "ar": "تم"
+ },
+ "l10n_fused_record_screen_fused_on_devicenoun_nothing_leaves_it_8a1e6a3d": {
+ "en": "Fused on %1$s. Nothing leaves it: no account, no cloud.",
+ "ar": "مدمج على %1$s. لا شيء يغادره: بلا حساب وبلا سحابة."
+ },
+ "l10n_fused_record_screen_noop_picks_the_best_sourced_number_5e48b86a": {
+ "en": "NOOP picks the best-sourced number and shows you where each came from. It\\'s for wellness and curiosity. It doesn\\'t diagnose or replace medical advice.",
+ "ar": "يختار NOOP الرقم من أفضل مصدر ويعرض لك مصدر كل رقم. الغرض منه العافية والفضول. لا يشخّص ولا يحل محل الاستشارة الطبية."
+ },
+ "l10n_fused_record_screen_noop_shows_the_winner_source_displayname_4a3892cc": {
+ "en": "NOOP shows the %1$s reading because it %2$s for this metric: a higher-trust source here, not a verdict that the others are wrong.",
+ "ar": "يعرض NOOP قراءة %1$s لأنها %2$s لهذا المقياس: مصدر أعلى ثقة هنا، لا حكمًا بأن البقية خاطئة."
+ },
+ "l10n_fused_record_screen_nothing_to_fuse_yet_30789c4e": {
+ "en": "Nothing to fuse yet",
+ "ar": "لا شيء لدمجه بعد"
+ },
+ "l10n_fused_record_screen_other_source_displayname_agrees_fusionformat_value_78e29c2a": {
+ "en": "%1$s agrees: %2$s",
+ "ar": "%1$s يتفق: %2$s"
+ },
+ "l10n_fused_record_screen_your_bands_report_different_numbers_here_03d20d81": {
+ "en": "Your bands report different numbers. Here\\'s every source, and the one NOOP is using.",
+ "ar": "أساورك تسجّل أرقامًا مختلفة. إليك كل المصادر، والمصدر الذي يستخدمه NOOP."
+ },
+ "l10n_fused_record_screen_your_data_fused_a740fd4a": {
+ "en": "Your Data, Fused",
+ "ar": "بياناتك، مدمجة"
+ },
+ "l10n_health_screen_5_yr_a_fitness_comparison_not_418aa11d": {
+ "en": "± 5 yr · a fitness comparison, not a biological age",
+ "ar": "± 5 سنوات · مقارنة لياقة، لا عمر بيولوجي"
+ },
+ "l10n_health_screen_a_wellness_estimate_from_your_habits_d00f36de": {
+ "en": "A wellness estimate from your habits, not a clinical biological age.",
+ "ar": "تقدير للعافية من عاداتك، لا عمر بيولوجي سريري."
+ },
+ "l10n_health_screen_active_energy_2d3288f9": {
+ "en": "Active Energy",
+ "ar": "الطاقة النشطة"
+ },
+ "l10n_health_screen_add_your_waist_for_a_more_accurate_vo_max_829f5e1e": {
+ "en": "Add your waist for a more accurate VO₂max",
+ "ar": "أضف محيط خصرك لتقدير VO₂max أدق"
+ },
+ "l10n_health_screen_as_of_latest_first_726f20bb": {
+ "en": "as of %1$s",
+ "ar": "حتى %1$s"
+ },
+ "l10n_health_screen_as_of_one_first_2b409612": {
+ "en": "as of %1$s",
+ "ar": "حتى %1$s"
+ },
+ "l10n_health_screen_baselines_learned_on_device_over_14_c107f375": {
+ "en": "Baselines learned on-device over 14 days. Bars read each signal against a ",
+ "ar": "خطوط الأساس مُتعلَّمة على الجهاز خلال 14 يومًا. تقرأ الأشرطة كل إشارة مقابل "
+ },
+ "l10n_health_screen_blood_o_9bf5ed9b": {
+ "en": "Blood O₂",
+ "ar": "أكسجين الدم"
+ },
+ "l10n_health_screen_blood_oxygen_a8ad9ff5": {
+ "en": "Blood Oxygen",
+ "ar": "أكسجين الدم"
+ },
+ "l10n_health_screen_cycle_phase_body_clock_and_illness_59e2d9a4": {
+ "en": "Cycle phase, body-clock and illness heads-up are approximations computed on your device from ",
+ "ar": "طور الدورة والساعة البيولوجية والإنذار بالمرض تقديرات محتسبة على جهازك من "
+ },
+ "l10n_health_screen_date_eb9a4bc1": {
+ "en": "Date",
+ "ar": "التاريخ"
+ },
+ "l10n_health_screen_drives_your_fitness_age_9d0d1219": {
+ "en": "Drives your Fitness Age",
+ "ar": "يحدّد عمرك اللياقي"
+ },
+ "l10n_health_screen_effort_8c974bc6": {
+ "en": "Effort",
+ "ar": "الجهد"
+ },
+ "l10n_health_screen_fitness_age_12383b4a": {
+ "en": "Fitness Age",
+ "ar": "العمر اللياقي"
+ },
+ "l10n_health_screen_fix_in_settings_d7472915": {
+ "en": "Fix in Settings",
+ "ar": "إصلاح في الإعدادات"
+ },
+ "l10n_health_screen_health_monitor_c4abc3fc": {
+ "en": "Health Monitor",
+ "ar": "مراقب الصحة"
+ },
+ "l10n_health_screen_heart_rate_dde6e8f7": {
+ "en": "Heart Rate",
+ "ar": "معدل النبض"
+ },
+ "l10n_health_screen_heart_rate_variability_20f0069e": {
+ "en": "Heart Rate Variability",
+ "ar": "تقلب معدل النبض"
+ },
+ "l10n_health_screen_helping_most_best_label_edee8773": {
+ "en": "Helping most: %1$s",
+ "ar": "الأكثر مساعدة: %1$s"
+ },
+ "l10n_health_screen_history_synced_8339779d": {
+ "en": "History synced",
+ "ar": "تمت مزامنة السجل"
+ },
+ "l10n_health_screen_holding_you_back_worst_label_863a1809": {
+ "en": "Holding you back: %1$s",
+ "ar": "ما يعيقك: %1$s"
+ },
+ "l10n_health_screen_how_accurate_is_this_dae653a8": {
+ "en": "How accurate is this?",
+ "ar": "ما مدى دقة هذا؟"
+ },
+ "l10n_health_screen_how_accurate_is_this_fitness_age_935c9a6d": {
+ "en": "How accurate is this Fitness Age?",
+ "ar": "ما مدى دقة العمر اللياقي هذا؟"
+ },
+ "l10n_health_screen_it_compares_your_resting_heart_rate_e83e00f5": {
+ "en": "It compares your resting heart rate and recent activity against people your age. ",
+ "ar": "يقارن نبضك في الراحة ونشاطك الأخير بأشخاص في عمرك. "
+ },
+ "l10n_health_screen_lab_book_f966c140": {
+ "en": "Lab Book",
+ "ar": "دفتر المختبر"
+ },
+ "l10n_health_screen_loading_33ce4174": {
+ "en": "Loading…",
+ "ar": "جارٍ التحميل…"
+ },
+ "l10n_health_screen_longer_ranges_unlock_as_more_history_d7da5fee": {
+ "en": "Longer ranges unlock as more history builds.",
+ "ar": "تُفتح النطاقات الأطول كلما تراكم السجل."
+ },
+ "l10n_health_screen_no_biometrics_yet_7c594a6c": {
+ "en": "No biometrics yet",
+ "ar": "لا قياسات حيوية بعد"
+ },
+ "l10n_health_screen_no_blood_oxygen_percentage_from_a_1d3d383e": {
+ "en": "No blood oxygen percentage from a WHOOP 4.0",
+ "ar": "لا نسبة أكسجين دم من WHOOP 4.0"
+ },
+ "l10n_health_screen_no_measured_temperature_for_these_nights_showing_the_differe_69d4efae": {
+ "en": "No measured temperature for these nights — showing the difference from your baseline. A sync re-scores recent nights and fills it in.",
+ "ar": "لا حرارة مقيسة لهذه الليالي — يُعرض الفارق عن خط أساسك. المزامنة تعيد احتساب الليالي الأخيرة وتملأه."
+ },
+ "l10n_health_screen_no_spo_import_or_health_value_408f8c55": {
+ "en": "No SpO₂ import or Health value",
+ "ar": "لا استيراد SpO₂ ولا قيمة من Health"
+ },
+ "l10n_health_screen_no_strap_connected_fb37b99e": {
+ "en": "No strap connected",
+ "ar": "لا سوار متصل"
+ },
+ "l10n_health_screen_not_enough_history_in_this_range_2da72f80": {
+ "en": "Not enough history in this range",
+ "ar": "لا يوجد سجل كافٍ في هذا النطاق"
+ },
+ "l10n_health_screen_not_enough_history_yet_0e2f93b6": {
+ "en": "Not enough history yet",
+ "ar": "لا يوجد سجل كافٍ بعد"
+ },
+ "l10n_health_screen_one_reading_so_far_your_trend_eaad57f2": {
+ "en": "One reading so far — your trend chart fills in here once a second ",
+ "ar": "قراءة واحدة حتى الآن — يمتلئ رسم اتجاهك هنا بمجرد وجود قراءة ثانية "
+ },
+ "l10n_health_screen_only_nights_with_a_measured_temperature_are_shown_the_others_b6f7c45b": {
+ "en": "Only nights with a measured temperature are shown; the others recorded a baseline difference only.",
+ "ar": "تُعرض فقط الليالي ذات الحرارة المقيسة؛ أما البقية فسجّلت فارقًا عن خط الأساس فحسب."
+ },
+ "l10n_health_screen_out_of_100_da0953a8": {
+ "en": "out of 100",
+ "ar": "من 100"
+ },
+ "l10n_health_screen_raw_counts_only_needs_an_import_d0e33552": {
+ "en": "Raw counts only — needs an import",
+ "ar": "أعداد خام فقط — يحتاج إلى استيراد"
+ },
+ "l10n_health_screen_raw_spo_ccfe80c1": {
+ "en": "Raw SpO₂",
+ "ar": "SpO₂ الخام"
+ },
+ "l10n_health_screen_recovery_ea924f72": {
+ "en": "Recovery",
+ "ar": "التعافي"
+ },
+ "l10n_health_screen_refresh_fitness_age_now_85fc516f": {
+ "en": "Refresh Fitness Age now",
+ "ar": "تحديث العمر اللياقي الآن"
+ },
+ "l10n_health_screen_resp_rate_1c48dbd8": {
+ "en": "Resp Rate",
+ "ar": "معدل التنفس"
+ },
+ "l10n_health_screen_respiratory_1cd8c175": {
+ "en": "Respiratory",
+ "ar": "التنفسي"
+ },
+ "l10n_health_screen_respiratory_rate_3fbb532f": {
+ "en": "Respiratory Rate",
+ "ar": "معدل التنفس"
+ },
+ "l10n_health_screen_rest_b79e5f48": {
+ "en": "Rest",
+ "ar": "الراحة"
+ },
+ "l10n_health_screen_resting_heart_rate_9700f4d8": {
+ "en": "Resting Heart Rate",
+ "ar": "معدل النبض في الراحة"
+ },
+ "l10n_health_screen_resting_hr_26677094": {
+ "en": "Resting HR",
+ "ar": "نبض الراحة"
+ },
+ "l10n_health_screen_sharpens_your_vo_max_c9d52991": {
+ "en": "Sharpens your VO₂max",
+ "ar": "يحسّن دقة VO₂max لديك"
+ },
+ "l10n_health_screen_skin_temp_a4affc5a": {
+ "en": "Skin Temp",
+ "ar": "حرارة الجلد"
+ },
+ "l10n_health_screen_skin_temperature_f59127f6": {
+ "en": "Skin Temperature",
+ "ar": "حرارة الجلد"
+ },
+ "l10n_health_screen_sleep_3cac34e6": {
+ "en": "Sleep",
+ "ar": "النوم"
+ },
+ "l10n_health_screen_source_6da13add": {
+ "en": "Source",
+ "ar": "المصدر"
+ },
+ "l10n_health_screen_spo_respiratory_rate_and_skin_temperature_0ae0ad8f": {
+ "en": "SpO₂, respiratory rate and skin temperature are sleep-window ",
+ "ar": "SpO₂ ومعدل التنفس وحرارة الجلد قياسات نافذة النوم "
+ },
+ "l10n_health_screen_steps_cdde4f20": {
+ "en": "Steps",
+ "ar": "الخطوات"
+ },
+ "l10n_health_screen_the_blood_oxygen_estimate_is_turned_4c403ab2": {
+ "en": "The blood oxygen estimate is turned off",
+ "ar": "تقدير أكسجين الدم مُعطَّل"
+ },
+ "l10n_health_screen_this_vital_needs_at_least_two_0e41b8b0": {
+ "en": "This vital needs at least two historical readings before NOOP can chart it.",
+ "ar": "يحتاج هذا المؤشر إلى قراءتين تاريخيتين على الأقل قبل أن يتمكن NOOP من رسمه."
+ },
+ "l10n_health_screen_value_8dce170d": {
+ "en": "Value",
+ "ar": "القيمة"
+ },
+ "l10n_health_screen_vital_signs_e7d9e1b1": {
+ "en": "Vital Signs",
+ "ar": "العلامات الحيوية"
+ },
+ "l10n_health_screen_vitality_be320b06": {
+ "en": "Vitality",
+ "ar": "الحيوية"
+ },
+ "l10n_health_screen_weight_height_and_waist_add_a_fd2699f5": {
+ "en": "Weight, height and waist add a VO₂max estimate. They don\\'t change the Fitness Age itself.",
+ "ar": "الوزن والطول ومحيط الخصر تضيف تقديرًا لـ VO₂max. وهي لا تغيّر العمر اللياقي نفسه."
+ },
+ "l10n_health_screen_your_data_fused_a740fd4a": {
+ "en": "Your Data, Fused",
+ "ar": "بياناتك، مدمجة"
+ },
+ "l10n_health_screen_your_strap_banks_the_raw_optical_b52a0f80": {
+ "en": "Your strap banks the raw optical channels, but NOOP does not turn them into a blood oxygen percentage, so this stays empty however long you wear it. Import a WHOOP export in Data Sources to fill it from your account history.",
+ "ar": "يخزّن سوارك القنوات الضوئية الخام، لكن NOOP لا يحوّلها إلى نسبة أكسجين في الدم، لذا يبقى هذا فارغًا مهما طال ارتداؤك له. استورد ملف تصدير WHOOP في «مصادر البيانات» لملئه من سجل حسابك."
+ },
+ "l10n_health_screen_your_strap_reports_a_blood_oxygen_349fe34a": {
+ "en": "Your strap reports a blood oxygen estimate, but it is unverified and off by default. Turn on the strap estimate in Settings to see it, or import a WHOOP export in Data Sources for the calibrated value.",
+ "ar": "يسجّل سوارك تقديرًا لأكسجين الدم، لكنه غير متحقق منه ومعطّل افتراضيًا. فعّل تقدير السوار في الإعدادات لرؤيته، أو استورد ملف تصدير WHOOP في «مصادر البيانات» للقيمة المعايرة."
+ },
+ "l10n_how_noop_works_screen_close_bbfa773e": {
+ "en": "Close",
+ "ar": "إغلاق"
+ },
+ "l10n_how_noop_works_screen_got_it_5b8027fa": {
+ "en": "Got it",
+ "ar": "فهمت"
+ },
+ "l10n_how_noop_works_screen_how_noop_works_3396b27a": {
+ "en": "How NOOP works",
+ "ar": "كيف يعمل NOOP"
+ },
+ "l10n_how_noop_works_screen_how_your_scores_are_computed_1b2d6c30": {
+ "en": "How your scores are computed",
+ "ar": "كيف تُحتسب درجاتك"
+ },
+ "l10n_how_noop_works_screen_how_your_scores_work_21a0e2be": {
+ "en": "How your scores work",
+ "ar": "كيف تعمل درجاتك"
+ },
+ "l10n_how_noop_works_screen_how_your_sleep_is_sorted_6a8e82e0": {
+ "en": "How your sleep is sorted",
+ "ar": "كيف يُصنَّف نومك"
+ },
+ "l10n_how_noop_works_screen_noop_never_makes_up_a_number_da29aca5": {
+ "en": "NOOP never makes up a number. When it can\\'t compute one honestly it tells you what\\'s missing and what to do, rather than showing a fake value.",
+ "ar": "لا يختلق NOOP رقمًا أبدًا. وحين يتعذّر احتسابه بصدق، يخبرك بما ينقص وما ينبغي فعله، بدلًا من عرض قيمة زائفة."
+ },
+ "l10n_how_noop_works_screen_noop_never_shows_you_a_number_d1db9958": {
+ "en": "NOOP never shows you a number it had to make up. If a score isn\\'t ready, ",
+ "ar": "لا يعرض NOOP أبدًا رقمًا اضطر لاختلاقه. وإذا لم تكن الدرجة جاهزة، "
+ },
+ "l10n_how_noop_works_screen_sleep_scores_recording_where_your_numbers_1b3e981f": {
+ "en": "Sleep · scores · recording · where your numbers come from",
+ "ar": "النوم · الدرجات · التسجيل · من أين تأتي أرقامك"
+ },
+ "l10n_how_noop_works_screen_what_recording_means_b896c422": {
+ "en": "What \"recording\" means",
+ "ar": "ماذا يعني «التسجيل»"
+ },
+ "l10n_how_noop_works_screen_where_your_numbers_come_from_e169963a": {
+ "en": "Where your numbers come from",
+ "ar": "من أين تأتي أرقامك"
+ },
+ "l10n_hrv_snapshot_screen_a_60_second_snapshot_of_your_35f03f7c": {
+ "en": "A 60-second snapshot of your beat-to-beat (R-R) intervals from the strap, cleaned ",
+ "ar": "لقطة مدتها 60 ثانية لفترات ما بين النبضات (R-R) من السوار، بعد تنظيفها "
+ },
+ "l10n_hrv_snapshot_screen_an_hrv_reading_needs_the_live_11b70bff": {
+ "en": "An HRV reading needs the live R-R stream. Open the Live screen and connect your strap, then come back.",
+ "ar": "تحتاج قراءة تقلب النبض إلى بث R-R المباشر. افتح شاشة «مباشر» ووصّل سوارك، ثم عد."
+ },
+ "l10n_hrv_snapshot_screen_beats_12aafda0": {
+ "en": "Beats",
+ "ar": "النبضات"
+ },
+ "l10n_hrv_snapshot_screen_close_hrv_reading_a29a99a1": {
+ "en": "Close HRV reading",
+ "ar": "إغلاق قراءة تقلب النبض"
+ },
+ "l10n_hrv_snapshot_screen_hrv_reading_a2cf71f3": {
+ "en": "HRV Reading",
+ "ar": "قراءة تقلب النبض"
+ },
+ "l10n_hrv_snapshot_screen_mean_hr_6c9272dd": {
+ "en": "Mean HR",
+ "ar": "متوسط النبض"
+ },
+ "l10n_hrv_snapshot_screen_not_enough_clean_beats_sit_still_0893e6c2": {
+ "en": "Not enough clean beats - sit still and try again. %1$s of ",
+ "ar": "لا توجد نبضات نظيفة كافية — اجلس دون حركة وحاول مجددًا. %1$s من "
+ },
+ "l10n_hydration_screen_a_simple_goal_that_adjusts_to_723dc08a": {
+ "en": "A simple goal that adjusts to your effort. General wellness guidance, not medical advice.",
+ "ar": "هدف بسيط يتكيّف مع مجهودك. إرشاد عام للعافية، لا نصيحة طبية."
+ },
+ "l10n_hydration_screen_bottle_3b8ac6c7": {
+ "en": "Bottle",
+ "ar": "زجاجة"
+ },
+ "l10n_hydration_screen_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_hydration_screen_clear_today_1be870ea": {
+ "en": "Clear today",
+ "ar": "مسح اليوم"
+ },
+ "l10n_hydration_screen_cup_04b95071": {
+ "en": "Cup",
+ "ar": "كوب"
+ },
+ "l10n_hydration_screen_custom_amount_f7219236": {
+ "en": "Custom amount",
+ "ar": "كمية مخصصة"
+ },
+ "l10n_hydration_screen_enter_any_amount_from_1_to_a85a639e": {
+ "en": "Enter any amount from 1 to %1$s ml.",
+ "ar": "أدخل أي كمية من 1 إلى %1$s مل."
+ },
+ "l10n_hydration_screen_from_health_connect_4ad037a3": {
+ "en": "From Health Connect",
+ "ar": "من Health Connect"
+ },
+ "l10n_hydration_screen_hydration_bdfb040f": {
+ "en": "Hydration",
+ "ar": "الترطيب"
+ },
+ "l10n_hydration_screen_kotlin_math_min_100_fraction_100_416d2889": {
+ "en": "%1$s percent of today\\'s goal",
+ "ar": "%1$s بالمئة من هدف اليوم"
+ },
+ "l10n_hydration_screen_kotlin_math_min_100_fraction_100_72f2dfde": {
+ "en": "%1$s%% of today\\'s goal",
+ "ar": "%1$s%% من هدف اليوم"
+ },
+ "l10n_hydration_screen_log_8bf95ea3": {
+ "en": "Log",
+ "ar": "تسجيل"
+ },
+ "l10n_hydration_screen_logged_today_0071a46c": {
+ "en": "Logged today",
+ "ar": "سُجِّل اليوم"
+ },
+ "l10n_hydration_screen_millilitres_ml_0fb0d2a4": {
+ "en": "Millilitres (ml)",
+ "ar": "مليلتر (مل)"
+ },
+ "l10n_hydration_screen_no_drinks_logged_yet_tap_sip_cc0d2f72": {
+ "en": "No drinks logged yet. Tap Sip, Cup or Bottle to start.",
+ "ar": "لم تُسجَّل مشروبات بعد. اضغط «رشفة» أو «كوب» أو «زجاجة» للبدء."
+ },
+ "l10n_hydration_screen_no_history_yet_933f417e": {
+ "en": "No history yet.",
+ "ar": "لا سجل بعد."
+ },
+ "l10n_hydration_screen_sip_78cca3e8": {
+ "en": "Sip",
+ "ar": "رشفة"
+ },
+ "l10n_hydration_screen_sip_hydrationgoal_sip_ml_ml_cup_287647f5": {
+ "en": "Sip %1$s ml · Cup %2$s ml · Bottle %3$s ml",
+ "ar": "رشفة %1$s مل · كوب %2$s مل · زجاجة %3$s مل"
+ },
+ "l10n_hydration_screen_totalml_toint_ml_522b262a": {
+ "en": "%1$s ml",
+ "ar": "%1$s مل"
+ },
+ "l10n_hydration_screen_undo_last_last_ml_330befc5": {
+ "en": "Undo last (%1$s ml)",
+ "ar": "تراجع عن الأخير (%1$s مل)"
+ },
+ "l10n_insights_hub_screen_dose_here_is_timing_later_in_1b1e9326": {
+ "en": "“Dose” here is timing (later in the day = stronger), not milligrams.",
+ "ar": "«الجرعة» هنا تعني التوقيت (كلما تأخر اليوم كان الأثر أقوى)، لا المليغرامات."
+ },
+ "l10n_insights_hub_screen_everything_here_is_a_pattern_in_ed2162a6": {
+ "en": "Everything here is a pattern in your own logged days: an association with an ",
+ "ar": "كل ما هنا نمط في أيامك المسجّلة أنت: ارتباط مع "
+ },
+ "l10n_insights_hub_screen_insights_b4510362": {
+ "en": "Insights",
+ "ar": "الرؤى"
+ },
+ "l10n_insights_hub_screen_log_alcohol_or_late_caffeine_with_dec9dadf": {
+ "en": "Log alcohol or late caffeine with an amount and NOOP fits a personal dose curve: ",
+ "ar": "سجّل الكحول أو الكافيين المتأخر مع الكمية، ويضبط NOOP منحنى جرعة شخصيًا: "
+ },
+ "l10n_insights_hub_screen_not_enough_overlap_between_your_journal_0ebdd7a2": {
+ "en": "Not enough overlap between your journal answers and ",
+ "ar": "لا يوجد تداخل كافٍ بين إجاباتك في اليوميات و"
+ },
+ "l10n_insights_hub_screen_per_extra_card_unitnoun_a92a5e91": {
+ "en": "Per extra %1$s",
+ "ar": "لكل %1$s إضافي"
+ },
+ "l10n_insights_hub_screen_reading_your_journal_and_outcomes_4a59af69": {
+ "en": "Reading your journal and outcomes…",
+ "ar": "جارٍ قراءة يومياتك ونتائجك…"
+ },
+ "l10n_insights_hub_screen_tomorrow_s_card_outcomename_ad70b7f9": {
+ "en": "Tomorrow’s %1$s",
+ "ar": "%1$s غدًا"
+ },
+ "l10n_insights_hub_screen_with_564f8c6e": {
+ "en": "With",
+ "ar": "مع"
+ },
+ "l10n_insights_hub_screen_without_cb735356": {
+ "en": "Without",
+ "ar": "بدون"
+ },
+ "l10n_insights_screen_baseline_e6ab7982": {
+ "en": "Baseline",
+ "ar": "خط الأساس"
+ },
+ "l10n_insights_screen_bounce_back_be2d66a4": {
+ "en": "Bounce back",
+ "ar": "سرعة التعافي"
+ },
+ "l10n_insights_screen_change_64fbd995": {
+ "en": "Change",
+ "ar": "التغيّر"
+ },
+ "l10n_insights_screen_charge_d4e1aee4": {
+ "en": "Charge",
+ "ar": "الشحن"
+ },
+ "l10n_insights_screen_compliance_68f0ae49": {
+ "en": "Compliance",
+ "ar": "الالتزام"
+ },
+ "l10n_insights_screen_end_a2bb9d34": {
+ "en": "End",
+ "ar": "إنهاء"
+ },
+ "l10n_insights_screen_end_experiment_0ed6d57f": {
+ "en": "End experiment",
+ "ar": "إنهاء التجربة"
+ },
+ "l10n_insights_screen_experiment_behaviour_selection_bcb29b58": {
+ "en": "Experiment behaviour: %1$s",
+ "ar": "سلوك التجربة: %1$s"
+ },
+ "l10n_insights_screen_experiment_progress_snapshot_dayselapsed_of_snapshot_ff62b228": {
+ "en": "Experiment progress %1$s of %2$s days",
+ "ar": "تقدّم التجربة %1$s من %2$s يوم"
+ },
+ "l10n_insights_screen_insights_b4510362": {
+ "en": "Insights",
+ "ar": "الرؤى"
+ },
+ "l10n_insights_screen_insights_read_your_journal_and_outcomes_6ec8aaf9": {
+ "en": "Insights read your journal and outcomes",
+ "ar": "الرؤى تقرأ يومياتك ونتائجك"
+ },
+ "l10n_insights_screen_intervention_e9b90c40": {
+ "en": "Intervention",
+ "ar": "التدخّل"
+ },
+ "l10n_insights_screen_log_at_least_one_behaviour_above_cf7c65a6": {
+ "en": "Log at least one behaviour above before starting an experiment.",
+ "ar": "سجّل سلوكًا واحدًا على الأقل أعلاه قبل بدء تجربة."
+ },
+ "l10n_insights_screen_mark_done_0911cce7": {
+ "en": "Mark done",
+ "ar": "وضع علامة تم"
+ },
+ "l10n_insights_screen_mark_done_today_471eb94a": {
+ "en": "Mark done today",
+ "ar": "وضع علامة تم اليوم"
+ },
+ "l10n_insights_screen_next_morning_61d1ea83": {
+ "en": "Next morning",
+ "ar": "صباح اليوم التالي"
+ },
+ "l10n_insights_screen_not_enough_overlap_between_your_journal_0ebdd7a2": {
+ "en": "Not enough overlap between your journal answers and ",
+ "ar": "لا يوجد تداخل كافٍ بين إجاباتك في اليوميات و"
+ },
+ "l10n_insights_screen_not_enough_overlapping_history_to_correlate_a552dbd4": {
+ "en": "Not enough overlapping history to correlate your metrics yet.",
+ "ar": "لا يوجد سجل متداخل كافٍ لربط مقاييسك بعد."
+ },
+ "l10n_insights_screen_pick_one_behaviour_you_log_one_bd34090e": {
+ "en": "Pick one behaviour you log, one outcome, and a short window. NOOP ",
+ "ar": "اختر سلوكًا واحدًا تسجّله، ونتيجة واحدة، ونافذة قصيرة. وNOOP "
+ },
+ "l10n_insights_screen_pre_filled_from_last_night_tap_ce81097c": {
+ "en": "Pre-filled from last night. Tap to confirm or change.",
+ "ar": "مملوء من الليلة الماضية. اضغط للتأكيد أو التغيير."
+ },
+ "l10n_insights_screen_ranked_lag_aware_which_of_your_e0e91b39": {
+ "en": "Ranked, lag-aware: which of your habits actually move your Charge, plus your personal alcohol/caffeine dose-response.",
+ "ar": "مرتَّب ومراعٍ للتأخير: أي عاداتك تحرّك شحنك فعلًا، مع استجابة الجرعة الشخصية للكحول/الكافيين."
+ },
+ "l10n_insights_screen_reading_your_journal_and_outcomes_4a59af69": {
+ "en": "Reading your journal and outcomes…",
+ "ar": "جارٍ قراءة يومياتك ونتائجك…"
+ },
+ "l10n_insights_screen_rest_b79e5f48": {
+ "en": "Rest",
+ "ar": "الراحة"
+ },
+ "l10n_insights_screen_rest_baseline_b3ac52a5": {
+ "en": "Rest baseline",
+ "ar": "خط أساس الراحة"
+ },
+ "l10n_insights_screen_rhr_04edf9b3": {
+ "en": "RHR",
+ "ar": "نبض الراحة"
+ },
+ "l10n_insights_screen_run_a_clean_personal_test_4da69781": {
+ "en": "Run a clean personal test",
+ "ar": "أجرِ اختبارًا شخصيًا نظيفًا"
+ },
+ "l10n_insights_screen_sessions_e11e37a9": {
+ "en": "Sessions",
+ "ar": "الجلسات"
+ },
+ "l10n_insights_screen_skip_3da47453": {
+ "en": "Skip",
+ "ar": "تخطٍ"
+ },
+ "l10n_insights_screen_skip_today_ed6a16a5": {
+ "en": "Skip today",
+ "ar": "تخطي اليوم"
+ },
+ "l10n_insights_screen_snapshot_dayselapsed_of_snapshot_durationdays_days_9a611f12": {
+ "en": "%1$s of %2$s days",
+ "ar": "%1$s من %2$s يوم"
+ },
+ "l10n_insights_screen_start_experiment_45a4b379": {
+ "en": "Start experiment",
+ "ar": "بدء التجربة"
+ },
+ "l10n_insights_screen_started_snapshot_startday_testing_snapshot_outcome_1839ef40": {
+ "en": "Started %1$s · testing %2$s",
+ "ar": "بدأت %1$s · نختبر %2$s"
+ },
+ "l10n_insights_screen_tag_a_few_sessions_of_the_97927401": {
+ "en": "Tag a few sessions of the same activity and NOOP will learn its personal recovery cost.",
+ "ar": "وسم بضع جلسات للنشاط نفسه وسيتعلّم NOOP تكلفتها الشخصية على التعافي."
+ },
+ "l10n_insights_screen_what_moves_you_ranked_patterns_in_7d89e628": {
+ "en": "What moves you. Ranked patterns in your own data, and your dose-response.",
+ "ar": "ما الذي يحرّكك. أنماط مرتَّبة في بياناتك أنت، واستجابتك للجرعة."
+ },
+ "l10n_insights_screen_with_564f8c6e": {
+ "en": "With",
+ "ar": "مع"
+ },
+ "l10n_insights_screen_without_cb735356": {
+ "en": "Without",
+ "ar": "بدون"
+ },
+ "l10n_intelligence_screen_0_unitformatter_effortscalemax_effortscale_scale_b5c43f56": {
+ "en": "0 - %1$s scale",
+ "ar": "مقياس 0 – %1$s"
+ },
+ "l10n_intelligence_screen_by_day_2e5d14ca": {
+ "en": "By Day",
+ "ar": "حسب اليوم"
+ },
+ "l10n_intelligence_screen_charge_weighs_your_heart_rate_variability_026745e6": {
+ "en": "Charge weighs your heart-rate variability against your personal baseline ",
+ "ar": "يوازن الشحن تقلب نبضك مقابل خط أساسك الشخصي "
+ },
+ "l10n_intelligence_screen_effort_8c974bc6": {
+ "en": "Effort",
+ "ar": "الجهد"
+ },
+ "l10n_intelligence_screen_estimate_from_today_s_effort_your_667d7560": {
+ "en": "Estimate from today\\'s effort, your typical sleep and your %1$s-night ",
+ "ar": "تقدير من جهد اليوم، ونومك المعتاد، و%1$s ليلة "
+ },
+ "l10n_intelligence_screen_how_this_works_b895a8c3": {
+ "en": "How this works",
+ "ar": "كيف يعمل هذا"
+ },
+ "l10n_intelligence_screen_intelligence_c698f940": {
+ "en": "Intelligence",
+ "ar": "الذكاء"
+ },
+ "l10n_intelligence_screen_no_scored_days_in_this_window_03f1165b": {
+ "en": "No scored days in this window. Widen the range or import more history.",
+ "ar": "لا أيام محتسبة في هذه النافذة. وسّع النطاق أو استورد سجلًا أكثر."
+ },
+ "l10n_intelligence_screen_no_scored_days_yet_sync_your_cdd47684": {
+ "en": "No scored days yet. Sync your strap to collect raw streams. Effort and Rest are ",
+ "ar": "لا أيام محتسبة بعد. زامن سوارك لجمع البيانات الخام. الجهد والراحة "
+ },
+ "l10n_intelligence_screen_you_ll_likely_wake_around_charge_ed3abe2e": {
+ "en": "You\\'ll likely wake around %1$s ± %2$s Charge if you sleep about ",
+ "ar": "من المرجّح أن تستيقظ بشحن نحو %1$s ± %2$s إذا نمت قرابة "
+ },
+ "l10n_intervals_screen_bond_your_strap_on_the_live_d799cbc2": {
+ "en": "Bond your strap on the Live screen to feel the transitions hands-free.",
+ "ar": "اقترن بسوارك من شاشة «مباشر» لتشعر بالانتقالات دون استخدام يديك."
+ },
+ "l10n_intervals_screen_interval_timer_1d703deb": {
+ "en": "Interval Timer",
+ "ar": "مؤقّت الفترات"
+ },
+ "l10n_intervals_screen_pause_to_change_work_rest_or_f54f9cc5": {
+ "en": "Pause to change work, rest, or rounds.",
+ "ar": "أوقف مؤقتًا لتغيير العمل أو الراحة أو الجولات."
+ },
+ "l10n_intervals_screen_reset_44c57abd": {
+ "en": "Reset",
+ "ar": "إعادة تعيين"
+ },
+ "l10n_intervals_screen_rest_b79e5f48": {
+ "en": "Rest",
+ "ar": "راحة"
+ },
+ "l10n_intervals_screen_rounds_ceeac4ac": {
+ "en": "Rounds",
+ "ar": "الجولات"
+ },
+ "l10n_intervals_screen_work_00040bab": {
+ "en": "Work",
+ "ar": "عمل"
+ },
+ "l10n_journal_log_add_a_custom_item_0dbd8f7c": {
+ "en": "Add a custom item…",
+ "ar": "إضافة عنصر مخصص…"
+ },
+ "l10n_journal_log_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_journal_log_display_name_c7874aaa": {
+ "en": "Display name",
+ "ar": "الاسم المعروض"
+ },
+ "l10n_journal_log_group_995a11e5": {
+ "en": "Group…",
+ "ar": "مجموعة…"
+ },
+ "l10n_journal_log_group_group_title_1f88621e": {
+ "en": "Group: %1$s",
+ "ar": "المجموعة: %1$s"
+ },
+ "l10n_journal_log_history_stays_under_the_original_question_dc91ce89": {
+ "en": "History stays under the original question so WHOOP imports still line up.",
+ "ar": "يبقى السجل تحت السؤال الأصلي حتى تظل عمليات استيراد WHOOP متوافقة."
+ },
+ "l10n_journal_log_journal_57d7f743": {
+ "en": "Journal",
+ "ar": "اليوميات"
+ },
+ "l10n_journal_log_rename_94ac9a58": {
+ "en": "Rename…",
+ "ar": "إعادة تسمية…"
+ },
+ "l10n_journal_log_rename_item_3d21d6ca": {
+ "en": "Rename item",
+ "ar": "إعادة تسمية العنصر"
+ },
+ "l10n_journal_log_save_efc007a3": {
+ "en": "Save",
+ "ar": "حفظ"
+ },
+ "l10n_journal_reminder_journal_57d7f743": {
+ "en": "Journal",
+ "ar": "اليوميات"
+ },
+ "l10n_journal_reminder_journal_reminder_0fdc0d9c": {
+ "en": "Journal reminder",
+ "ar": "تذكير اليوميات"
+ },
+ "l10n_journal_reminder_log_today_s_journal_cb33be3e": {
+ "en": "Log today\\'s journal",
+ "ar": "سجّل يوميات اليوم"
+ },
+ "l10n_journal_reminder_logged_today_0071a46c": {
+ "en": "Logged today",
+ "ar": "سُجِّل اليوم"
+ },
+ "l10n_journal_reminder_open_journal_bbd88cc1": {
+ "en": "Open journal",
+ "ar": "فتح اليوميات"
+ },
+ "l10n_journal_reminder_show_a_today_card_reminding_you_to_log_your_journal_8228bc77": {
+ "en": "Show a Today card reminding you to log your journal",
+ "ar": "عرض بطاقة في «اليوم» تذكّرك بتسجيل يومياتك"
+ },
+ "l10n_journal_reminder_tap_a_day_to_catch_up_10d16728": {
+ "en": "Tap a day to catch up",
+ "ar": "اضغط على يوم لتعويض ما فاتك"
+ },
+ "l10n_lab_book_screen_a_private_notebook_not_a_medical_f63242cb": {
+ "en": "A private notebook, not a medical service.",
+ "ar": "دفتر خاص، لا خدمة طبية."
+ },
+ "l10n_lab_book_screen_about_lab_book_37bf2691": {
+ "en": "About Lab Book",
+ "ar": "عن دفتر المختبر"
+ },
+ "l10n_lab_book_screen_all_stays_on_this_phone_nothing_9a966c1c": {
+ "en": "All stays on this phone. Nothing is sent anywhere.",
+ "ar": "كل شيء يبقى على هذا الهاتف. لا يُرسل شيء إلى أي مكان."
+ },
+ "l10n_lab_book_screen_bring_in_a_markers_csv_date_1090bec2": {
+ "en": "Bring in a markers CSV (date, marker, value, unit). Names that match the catalog ",
+ "ar": "استورد ملف CSV للمؤشرات (التاريخ، المؤشر، القيمة، الوحدة). الأسماء المطابقة للفهرس "
+ },
+ "l10n_lab_book_screen_choose_a_wearable_signal_to_compare_05a4b2bc": {
+ "en": "Choose a wearable signal to compare",
+ "ar": "اختر إشارة من الجهاز الملبوس للمقارنة"
+ },
+ "l10n_lab_book_screen_clear_719ea396": {
+ "en": "Clear",
+ "ar": "مسح"
+ },
+ "l10n_lab_book_screen_delete_this_reading_b5c92bda": {
+ "en": "Delete this reading",
+ "ar": "حذف هذه القراءة"
+ },
+ "l10n_lab_book_screen_import_readings_7ad0d28f": {
+ "en": "Import readings",
+ "ar": "استيراد قراءات"
+ },
+ "l10n_lab_book_screen_it_s_a_notebook_not_a_45835e98": {
+ "en": "It\\'s a notebook, not a lab. NOOP lines up the numbers you enter. It doesn\\'t test, read, or judge them. Not medical advice.",
+ "ar": "إنه دفتر، لا مختبر. يرتّب NOOP الأرقام التي تدخلها. لا يجري تحاليل ولا يقرأها ولا يحكم عليها. وليس نصيحة طبية."
+ },
+ "l10n_lab_book_screen_keep_your_own_numbers_here_1ca6c8ed": {
+ "en": "Keep your own numbers here",
+ "ar": "احتفظ بأرقامك هنا"
+ },
+ "l10n_lab_book_screen_lab_book_f966c140": {
+ "en": "Lab Book",
+ "ar": "دفتر المختبر"
+ },
+ "l10n_lab_book_screen_lab_book_is_a_private_notebook_d07380ca": {
+ "en": "Lab Book is a private notebook, not a medical service. NOOP stores and lines up the numbers ",
+ "ar": "دفتر المختبر دفتر خاص، لا خدمة طبية. يخزّن NOOP الأرقام ويرتّبها "
+ },
+ "l10n_lab_book_screen_lining_them_up_c59ee297": {
+ "en": "Lining them up…",
+ "ar": "جارٍ ترتيبها…"
+ },
+ "l10n_lab_book_screen_n_readings_line_up_but_there_27bfb506": {
+ "en": "%1$s readings line up, but there isn\\'t enough variation to compute a relationship.",
+ "ar": "%1$s قراءة مرتَّبة، لكن التباين غير كافٍ لحساب علاقة."
+ },
+ "l10n_lab_book_screen_n_readings_used_strengthword_r_directionword_298baf9e": {
+ "en": "%1$s readings used · %2$s %3$s association. This is your own data ",
+ "ar": "%1$s قراءة مستخدمة · ارتباط %2$s %3$s. هذه بياناتك أنت "
+ },
+ "l10n_lab_book_screen_pick_a_wearable_signal_resting_hr_8d8b01a2": {
+ "en": "Pick a wearable signal (resting HR, HRV, sleep, Charge, weight…) to line it up against this ",
+ "ar": "اختر إشارة من الجهاز الملبوس (نبض الراحة، تقلب النبض، النوم، الشحن، الوزن…) لترتيبها مقابل هذا "
+ },
+ "l10n_lab_book_screen_read_the_full_lab_book_note_3a250750": {
+ "en": "Read the full Lab Book note",
+ "ar": "اقرأ ملاحظة دفتر المختبر كاملة"
+ },
+ "l10n_lab_book_screen_read_the_full_note_3fd0e4d1": {
+ "en": "Read the full note",
+ "ar": "اقرأ الملاحظة كاملة"
+ },
+ "l10n_lab_book_screen_reading_your_logbook_e31992b0": {
+ "en": "Reading your logbook…",
+ "ar": "جارٍ قراءة دفترك…"
+ },
+ "l10n_lab_book_screen_these_are_your_own_numbers_shown_7667508d": {
+ "en": "These are your own numbers shown back to you. NOOP doesn\\'t decide whether any value is normal, high or low.",
+ "ar": "هذه أرقامك أنت معروضة عليك. ولا يقرر NOOP ما إذا كانت أي قيمة طبيعية أو مرتفعة أو منخفضة."
+ },
+ "l10n_lab_book_screen_type_in_a_blood_pressure_reading_c276d418": {
+ "en": "Type in a blood-pressure reading or a cholesterol value from your last appointment. ",
+ "ar": "أدخل قراءة ضغط دم أو قيمة كوليسترول من موعدك الأخير. "
+ },
+ "l10n_lab_book_screen_what_lab_book_is_and_isn_f28686df": {
+ "en": "What Lab Book is (and isn\\'t)",
+ "ar": "ما هو دفتر المختبر (وما ليس هو)"
+ },
+ "l10n_live_screen_avg_cdc93143": {
+ "en": "Avg",
+ "ar": "متوسط"
+ },
+ "l10n_live_screen_buzz_edbd47b2": {
+ "en": "Buzz",
+ "ar": "اهتزاز"
+ },
+ "l10n_live_screen_can_t_connect_your_strap_s_cf78be83": {
+ "en": "Can\\'t connect - your strap\\'s pairing was reset",
+ "ar": "تعذّر الاتصال — أُعيد ضبط اقتران سوارك"
+ },
+ "l10n_live_screen_charging_5f99fe21": {
+ "en": "Charging",
+ "ar": "قيد الشحن"
+ },
+ "l10n_live_screen_distance_42320809": {
+ "en": "Distance",
+ "ar": "المسافة"
+ },
+ "l10n_live_screen_effort_8c974bc6": {
+ "en": "Effort",
+ "ar": "الجهد"
+ },
+ "l10n_live_screen_end_a2bb9d34": {
+ "en": "End",
+ "ar": "إنهاء"
+ },
+ "l10n_live_screen_end_workout_3e8d6238": {
+ "en": "End workout",
+ "ar": "إنهاء التمرين"
+ },
+ "l10n_live_screen_history_synced_relativeago_at_0ece753a": {
+ "en": "History synced %1$s",
+ "ar": "تمت مزامنة السجل %1$s"
+ },
+ "l10n_live_screen_hr_f187928f": {
+ "en": "HR",
+ "ar": "النبض"
+ },
+ "l10n_live_screen_live_body_console_54838e06": {
+ "en": "Live Body Console",
+ "ar": "لوحة الجسد المباشرة"
+ },
+ "l10n_live_screen_manage_devices_e5c277ff": {
+ "en": "Manage devices",
+ "ar": "إدارة الأجهزة"
+ },
+ "l10n_live_screen_max_hr_6acd9e07": {
+ "en": "Max HR",
+ "ar": "أقصى نبض"
+ },
+ "l10n_live_screen_pace_7a9a6226": {
+ "en": "Pace",
+ "ar": "الوتيرة"
+ },
+ "l10n_live_screen_peak_c83dbbd3": {
+ "en": "Peak",
+ "ar": "الذروة"
+ },
+ "l10n_live_screen_refresh_56e3badc": {
+ "en": "Refresh",
+ "ar": "تحديث"
+ },
+ "l10n_live_screen_rmssd_roundtoint_ms_9e6c0887": {
+ "en": "%1$s ms",
+ "ar": "%1$s مللي ثانية"
+ },
+ "l10n_live_screen_scan_and_connect_to_start_a_3e27b47b": {
+ "en": "Scan and connect to start a live stream.",
+ "ar": "ابحث واتصل لبدء بث مباشر."
+ },
+ "l10n_live_screen_session_f7f1997c": {
+ "en": "Session",
+ "ar": "جلسة"
+ },
+ "l10n_live_screen_signal_trust_4a91fe00": {
+ "en": "Signal Trust",
+ "ar": "موثوقية الإشارة"
+ },
+ "l10n_live_screen_start_a_live_stream_419bf5aa": {
+ "en": "Start a live stream",
+ "ar": "بدء بث مباشر"
+ },
+ "l10n_live_screen_start_workout_d0f3f2cd": {
+ "en": "Start workout",
+ "ar": "بدء تمرين"
+ },
+ "l10n_live_screen_strap_02b88eeb": {
+ "en": "Strap",
+ "ar": "السوار"
+ },
+ "l10n_live_screen_take_an_hrv_reading_20ecb2d2": {
+ "en": "Take an HRV reading",
+ "ar": "إجراء قراءة لتقلب النبض"
+ },
+ "l10n_live_screen_top_zone_282d0f66": {
+ "en": "Top zone",
+ "ar": "المنطقة العليا"
+ },
+ "l10n_live_screen_whoop_5_0_mg_pairs_with_b93143f2": {
+ "en": "WHOOP 5.0/MG pairs with one app at a time. If a scan finds nothing, unpair it in ",
+ "ar": "يقترن WHOOP 5.0/MG بتطبيق واحد في كل مرة. إذا لم يجد البحث شيئًا، فألغِ اقترانه في "
+ },
+ "l10n_live_screen_zone_zone_b8e9c0f9": {
+ "en": "ZONE %1$s",
+ "ar": "المنطقة %1$s"
+ },
+ "l10n_live_session_screen_above_6370c271": {
+ "en": "Above",
+ "ar": "أعلى"
+ },
+ "l10n_live_session_screen_below_5ba07a31": {
+ "en": "Below",
+ "ar": "أدنى"
+ },
+ "l10n_live_session_screen_done_e9b450d1": {
+ "en": "Done",
+ "ar": "تم"
+ },
+ "l10n_live_session_screen_end_session_8c0f4c33": {
+ "en": "End session",
+ "ar": "إنهاء الجلسة"
+ },
+ "l10n_live_session_screen_guarded_for_elapsedclock_snap_elapsedsec_tolong_a61f2d32": {
+ "en": "Guarded for %1$s",
+ "ar": "تحت الحماية %1$s"
+ },
+ "l10n_live_session_screen_in_band_2c23d86e": {
+ "en": "In band",
+ "ar": "ضمن النطاق"
+ },
+ "l10n_live_session_screen_live_session_73c925a5": {
+ "en": "Live Session",
+ "ar": "جلسة مباشرة"
+ },
+ "l10n_live_session_screen_session_ring_statelabel_long_press_to_42ac1b3b": {
+ "en": "Session ring. %1$s. Long-press to show heart rate.",
+ "ar": "حلقة الجلسة. %1$s. اضغط مطولًا لعرض معدل النبض."
+ },
+ "l10n_live_session_screen_session_summary_f9418e16": {
+ "en": "Session summary",
+ "ar": "ملخص الجلسة"
+ },
+ "l10n_live_session_screen_the_strap_signal_was_gone_for_57a2fead": {
+ "en": "The strap signal was gone for 10 minutes, so the session ended itself.",
+ "ar": "انقطعت إشارة السوار لعشر دقائق، فأنهت الجلسة نفسها."
+ },
+ "l10n_live_workout_screen_avg_cdc93143": {
+ "en": "Avg",
+ "ar": "متوسط"
+ },
+ "l10n_live_workout_screen_cadence_68af11f0": {
+ "en": "Cadence",
+ "ar": "الإيقاع"
+ },
+ "l10n_live_workout_screen_cancel_77dfd213": {
+ "en": "Cancel",
+ "ar": "إلغاء"
+ },
+ "l10n_live_workout_screen_effort_8c974bc6": {
+ "en": "Effort",
+ "ar": "الجهد"
+ },
+ "l10n_live_workout_screen_end_this_workout_4869c76a": {
+ "en": "End this workout?",
+ "ar": "إنهاء هذا التمرين؟"
+ },
+ "l10n_live_workout_screen_end_workout_3e8d6238": {
+ "en": "End workout",
+ "ar": "إنهاء التمرين"
+ },
+ "l10n_live_workout_screen_of_c5e92da0": {
+ "en": "of %1$d",
+ "ar": "من %1$d"
+ },
+ "l10n_live_workout_screen_peak_c83dbbd3": {
+ "en": "Peak",
+ "ar": "الذروة"
+ },
+ "l10n_live_workout_screen_power_7548ab52": {
+ "en": "Power",
+ "ar": "القدرة"
+ },
+ "l10n_live_workout_screen_speed_2d2cb022": {
+ "en": "Speed",
+ "ar": "السرعة"
+ },
+ "l10n_live_workout_screen_this_stops_recording_and_saves_what_3e17a23e": {
+ "en": "This stops recording and saves what\\'s captured so far. It can\\'t be resumed.",
+ "ar": "يوقف هذا التسجيل ويحفظ ما التُقط حتى الآن. ولا يمكن استئنافه."
+ },
+ "l10n_live_workout_screen_z_z_d3991a28": {
+ "en": "Z%1$s",
+ "ar": "م%1$s"
+ },
+ "l10n_marker_editor_screen_add_a_reading_ed7708ee": {
+ "en": "Add a reading",
+ "ar": "إضافة قراءة"
+ },
+ "l10n_marker_editor_screen_date_taken_76edfcab": {
+ "en": "Date taken",
+ "ar": "تاريخ القياس"
+ },
+ "l10n_marker_editor_screen_entered_together_stored_as_two_markers_af8f3ba0": {
+ "en": "Entered together; stored as two markers so each lines up cleanly against your signals.",
+ "ar": "أُدخلا معًا؛ ويُخزَّنان كمؤشرين لينتظم كل منهما بوضوح مقابل إشاراتك."
+ },
+ "l10n_marker_editor_screen_lab_book_keeps_your_own_numbers_9085ce2c": {
+ "en": "Lab Book keeps your own numbers - it doesn\\'t test, read, or judge them, and it\\'s not medical ",
+ "ar": "يحتفظ دفتر المختبر بأرقامك أنت — لا يجري تحاليل ولا يقرأها ولا يحكم عليها، وليس طبيًا "
+ },
+ "l10n_marker_editor_screen_no_match_add_it_as_a_ad5c59ec": {
+ "en": "No match. Add it as a custom marker below.",
+ "ar": "لا تطابق. أضفه كمؤشر مخصص أدناه."
+ },
+ "l10n_marker_editor_screen_noop_never_fills_this_in_it_7be9653b": {
+ "en": "NOOP never fills this in - it only shows back exactly what you type from your own report.",
+ "ar": "لا يملأ NOOP هذا أبدًا — بل يعرض بالضبط ما تدخله من تقريرك أنت."
+ },
+ "l10n_marker_editor_screen_search_markers_103981e0": {
+ "en": "Search markers",
+ "ar": "البحث في المؤشرات"
+ }
+ },
+ "_glossary_note": "Terminology anchor, not a literal expectation. Arabic inflects - the definite article, prepositions and verb forms all alter the stored surface form - so a plain substring check will under-report these terms. Keep the terms, or change them everywhere at once."
+}
diff --git a/Tools/i18n_audit.py b/Tools/i18n_audit.py
index 85a2fe94e9..a2f8826289 100644
--- a/Tools/i18n_audit.py
+++ b/Tools/i18n_audit.py
@@ -435,7 +435,7 @@ def record(span_start: int, span_end: int) -> None:
continue
seen.add(offset)
line_no = text.count("\n", 0, offset) + 1
- findings.append((str(path.relative_to(ROOT)), line_no, literal))
+ findings.append((path.relative_to(ROOT).as_posix(), line_no, literal))
# The call's own first (content) argument only — catches
# `Text(if (x) "a" else "b")` — never the whole call span, which
@@ -856,7 +856,7 @@ def scan_ios() -> tuple[list[tuple[str, int, str]], dict[str, list[str]]]:
continue
entry = swift_catalog_lookup(cat, literal)
line_no = text.count("\n", 0, offset) + 1
- rel = str(path.relative_to(ROOT))
+ rel = path.relative_to(ROOT).as_posix()
if entry is None:
hardcoded.append((rel, line_no, literal))
continue
@@ -864,7 +864,7 @@ def scan_ios() -> tuple[list[tuple[str, int, str]], dict[str, list[str]]]:
continue
for lang in LANGS:
if not _is_translated(entry, lang):
- lang_gaps[lang].append(f"{catalog_path.relative_to(ROOT)} :: {literal!r}")
+ lang_gaps[lang].append(f"{catalog_path.relative_to(ROOT).as_posix()} :: {literal!r}")
for lang in lang_gaps:
lang_gaps[lang] = sorted(set(lang_gaps[lang]))
return hardcoded, lang_gaps
@@ -926,13 +926,33 @@ def extra_locale_allowance() -> dict[str, int]:
FORMAT_SPECIFIER_PATTERN = re.compile(r"%(?:\d+\$)?[@#0\-+ ]*[\d.]*(?:ll|l|h)?[@dfsu]|%%")
+# Multi-word product names that travel verbatim into Latin-script locales. The two-word floor below
+# already lets a ONE-word brand through ("HRV", "Strava"); it cannot see a two-word one, so "iCloud
+# Drive" repeated verbatim in German reads as an untranslated echo when it is the correct rendering.
+# Only strings that are ENTIRELY brand are exempted (see `_is_pure_brand_phrase`), so "Apple Health
+# sync" stays gated on its translatable word. CJK locales that DO translate these are unaffected —
+# they differ from the source, so they were never counted as echoes in the first place.
+BRAND_PHRASES = ("iCloud Drive",)
+
+
+def _is_pure_brand_phrase(text: str) -> bool:
+ """Whether a string is nothing but brand names, placeholders and punctuation."""
+ stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text)
+ for brand in BRAND_PHRASES:
+ stripped = stripped.replace(brand, " ")
+ return not re.search(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE)
+
+
def _has_translatable_words(text: str) -> bool:
"""Whether a string carries enough real words that an identical translation is suspicious.
Strips format specifiers first: "%@ · n = %lld" / "%1$s: %2$s" are placeholders and punctuation
with nothing to translate, so a locale repeating them verbatim is CORRECT, not a gap. Two words is
the floor — one word is very often a term that legitimately travels ("HRV", "Yoga", a brand name).
+ A string that is entirely a multi-word brand is the same case one size up (see [BRAND_PHRASES]).
"""
+ if _is_pure_brand_phrase(text):
+ return False
stripped = FORMAT_SPECIFIER_PATTERN.sub(" ", text)
return len(re.findall(r"[^\W\d_]{2,}", stripped, flags=re.UNICODE)) >= 2
@@ -948,7 +968,7 @@ def _ios_echoed_counts() -> dict[str, int]:
cat = json.loads(catalog_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
- rel = str(catalog_path.relative_to(ROOT))
+ rel = catalog_path.relative_to(ROOT).as_posix()
for key, entry in (cat.get("strings") or {}).items():
if not _has_translatable_words(key):
continue
@@ -984,7 +1004,7 @@ def _android_echoed_counts() -> dict[str, int]:
loc = {n.attrib["name"]: (n.text or "") for n in ET.parse(path).getroot().findall("string")}
except ET.ParseError:
continue
- rel = str(path.relative_to(ROOT))
+ rel = path.relative_to(ROOT).as_posix()
n = sum(1 for k, base_val in translatable.items() if loc.get(k) == base_val)
if n:
counts[f"{rel} {lang}"] = n
@@ -1126,7 +1146,7 @@ def ci_check(base_ref: str) -> int:
# and gate them below. Reloading each catalog for a second pass wasted a full re-parse of a
# 3255-string file.
for extra in sorted(shipped_apple_langs(cat) - set(LANGS)):
- extra_apple_gaps[f"{catalog_path.relative_to(ROOT)}:{extra}"] = sum(
+ extra_apple_gaps[f"{catalog_path.relative_to(ROOT).as_posix()}:{extra}"] = sum(
1 for v in cat.get("strings", {}).values()
if v.get("shouldTranslate") is not False and not _is_translated(v, extra)
)
diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt
index d854b49885..6496f6267d 100644
--- a/Tools/i18n_extra_locale_baseline.txt
+++ b/Tools/i18n_extra_locale_baseline.txt
@@ -4,7 +4,7 @@
# tolerance in LANGS, which is why they are at 100%.
Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:it 81
-Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:ru 54
+Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:ru 10
Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:zh-Hans 75
Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:zh-Hant 81
NOOPWatch/Localizable.xcstrings:it 0
@@ -15,7 +15,8 @@ NOOPWatchComplications/Localizable.xcstrings:it 25
NOOPWatchComplications/Localizable.xcstrings:zh-Hans 25
NOOPWatchComplications/Localizable.xcstrings:zh-Hant 25
Strand/Resources/Localizable.xcstrings:it 179
-Strand/Resources/Localizable.xcstrings:ru 156
+Strand/Resources/Localizable.xcstrings:ru 1
Strand/Resources/Localizable.xcstrings:zh-Hans 129
Strand/Resources/Localizable.xcstrings:zh-Hant 179
+values-ru/strings.xml 121
values-zh/strings.xml 49
diff --git a/Tools/linux-capture/analyze_command_responses.py b/Tools/linux-capture/analyze_command_responses.py
index d4ba5c7bff..b6fe9774f0 100644
--- a/Tools/linux-capture/analyze_command_responses.py
+++ b/Tools/linux-capture/analyze_command_responses.py
@@ -11,6 +11,7 @@
import json
import sys
+from capture_io import configure_utf8_stdio
import whoop_frame as wf
# Response command numbers (CommandNumber enum) → human name.
@@ -45,7 +46,8 @@ def main():
if len(sys.argv) < 2:
print(__doc__)
return
- data = json.load(open(sys.argv[1]))
+ with open(sys.argv[1], encoding="utf-8") as f:
+ data = json.load(f)
seen = {}
for r in data:
h = bytes.fromhex(r["hex"])
@@ -67,4 +69,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/analyze_v25_waveform.py b/Tools/linux-capture/analyze_v25_waveform.py
index e1f78bedaf..52952abb42 100644
--- a/Tools/linux-capture/analyze_v25_waveform.py
+++ b/Tools/linux-capture/analyze_v25_waveform.py
@@ -52,6 +52,8 @@
import struct
import sys
+from capture_io import configure_utf8_stdio
+
# --- v25 record geometry (WHOOP 4.0 envelope: 0xAA, len u16, crc8, type@4, version@5, ...) -----------
TYPE_HISTORICAL = 0x2F # 47, at byte 4
VERSION_V25 = 25 # at byte 5
@@ -164,7 +166,8 @@ def load_corpus(argv):
print("No corpus given — running the DEMO on the bundled resting frames (which cannot pin the\n"
"span; that is the point). Build a real corpus (--template) with a HR ≠ 60.\n")
return [BUNDLED_RESTING]
- return json.load(open(paths[0]))
+ with open(paths[0], encoding="utf-8") as f:
+ return json.load(f)
def analyze(corpus):
@@ -266,6 +269,7 @@ def selftest():
if __name__ == "__main__":
+ configure_utf8_stdio()
if "--selftest" in sys.argv:
selftest()
else:
diff --git a/Tools/linux-capture/analyze_v26_waveform.py b/Tools/linux-capture/analyze_v26_waveform.py
index e56c16aac3..430a474e81 100644
--- a/Tools/linux-capture/analyze_v26_waveform.py
+++ b/Tools/linux-capture/analyze_v26_waveform.py
@@ -26,6 +26,8 @@
import sys
from collections import Counter
+from capture_io import configure_utf8_stdio
+
WAVE_START, WAVE_END = 27, 75 # 24 LE-i16 samples per record
SAMPLE_RATE_HZ = 24 # 24 samples/record, 1 record/second
@@ -56,7 +58,8 @@ def acf(x, lag):
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "capture_hist_ack.json"
- frames = [bytes.fromhex(c["hex"]) for c in json.load(open(path))]
+ with open(path, encoding="utf-8") as f:
+ frames = [bytes.fromhex(c["hex"]) for c in json.load(f)]
v26 = [r for r in frames if len(r) == 88 and r[8] == 47 and r[9] == 26]
v18 = [r for r in frames if len(r) == 124 and r[8] == 47 and r[9] == 18]
if not v26:
@@ -102,4 +105,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/capture_io.py b/Tools/linux-capture/capture_io.py
new file mode 100644
index 0000000000..045065373d
--- /dev/null
+++ b/Tools/linux-capture/capture_io.py
@@ -0,0 +1,11 @@
+"""Shared text-console setup for the executable Linux capture tools."""
+
+import sys
+
+
+def configure_utf8_stdio() -> None:
+ """Keep Unicode diagnostics printable when the host defaults to a legacy code page."""
+ for stream in (sys.stdout, sys.stderr):
+ reconfigure = getattr(stream, "reconfigure", None)
+ if callable(reconfigure):
+ reconfigure(encoding="utf-8")
diff --git a/Tools/linux-capture/correlate_ground_truth.py b/Tools/linux-capture/correlate_ground_truth.py
index 16434e5191..b5aab754b9 100644
--- a/Tools/linux-capture/correlate_ground_truth.py
+++ b/Tools/linux-capture/correlate_ground_truth.py
@@ -29,6 +29,7 @@
import sys
import zipfile
+from capture_io import configure_utf8_stdio
import whoop_frame as wf
# --- Ground-truth CSV loading ----------------------------------------------------------------------
@@ -301,7 +302,7 @@ def main():
help="restrict to one record type byte, e.g. 0x2f")
args = p.parse_args()
- with open(args.capture) as f:
+ with open(args.capture, encoding="utf-8") as f:
records = json.load(f)
rows = load_ground_truth(args.export)
if not rows:
@@ -344,4 +345,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/decode_features.py b/Tools/linux-capture/decode_features.py
index 132612be57..0a81b80fd4 100644
--- a/Tools/linux-capture/decode_features.py
+++ b/Tools/linux-capture/decode_features.py
@@ -81,7 +81,11 @@ def rr_stats(rr):
def feature_to_rows(rec):
"""Map a normalized decoded record -> {second, rr[], ppg[], event}. See module/spec for rules."""
out = {"second": None, "rr": [], "ppg": [], "event": None}
- if not rec.get("crc_ok", True):
+ # The gate is the decoder's FULL verdict (`frame.ok`: start-of-frame, minimum and exact length,
+ # header checksum and payload CRC32 together), not the payload CRC alone — a frame whose CRC32
+ # verifies but whose envelope does not is exactly the class that used to reach these tables. A
+ # record without a verdict is rejected, never assumed to pass.
+ if not rec.get("ok", False):
return out
p = rec.get("parsed") or {}
itype = rec.get("inner_type")
@@ -190,11 +194,16 @@ def _flatten_fields(frame):
def normalize_decode_record(raw, src_frame, device_id):
"""Adapt one whoop-decode --json object + its source frames row -> the normalized record dict.
- whoop-decode emits {family, char, frame:{crcOK, seq, fields:[{name,value,cat,off},...]}}. We
- flatten frame.fields (minus envelope/raw cats) into `parsed`, take crc from frame.crcOK, version
- from the `hist_version` field (whoop4 historical has no such field and is always v24), take unix
- from the decoded `unix` field (falling back to the source frames row), and inner_type from the
- source frames row (id,hex,char,recv_ms,unix,hr,inner_type).
+ whoop-decode emits {family, char, frame:{ok, crcOK, rejectReason, seq,
+ fields:[{name,value,cat,off},...]}}. We flatten frame.fields (minus envelope/raw cats) into
+ `parsed`, take the integrity verdict from frame.ok, version from the `hist_version` field (whoop4
+ historical has no such field and is always v24), take unix from the decoded `unix` field (falling
+ back to the source frames row), and inner_type from the source frames row
+ (id,hex,char,recv_ms,unix,hr,inner_type).
+
+ `frame.ok` is the decoder's full verdict; `frame.crcOK` reports the payload CRC32 alone and is
+ therefore not the gate. A missing verdict yields False: absence of the field is a rejection, not
+ a pass. (`frame.rejectReason` names the cause and is available for diagnosis.)
"""
raw = raw if isinstance(raw, dict) else {}
frame = raw.get("frame") or {}
@@ -205,7 +214,7 @@ def normalize_decode_record(raw, src_frame, device_id):
rr = (frame.get("parsed") or {}).get("rr_intervals")
if rr is not None:
parsed["rr_intervals"] = rr
- crc_ok = bool(frame.get("crcOK", True))
+ ok = bool(frame.get("ok", False))
version = parsed.get("hist_version")
if version is None and raw.get("family") == "whoop4":
version = 24
@@ -215,7 +224,7 @@ def normalize_decode_record(raw, src_frame, device_id):
if unix is None:
unix = src_frame[4]
return {"device_id": device_id, "unix": unix, "inner_type": src_frame[6],
- "version": version, "crc_ok": crc_ok, "parsed": parsed}
+ "version": version, "ok": ok, "parsed": parsed}
def find_whoop_decode():
@@ -242,7 +251,7 @@ def run_whoop_decode(frames_json, binary=None):
binary = binary or find_whoop_decode()
fd, path = tempfile.mkstemp(suffix=".json")
try:
- with os.fdopen(fd, "w") as f:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(frames_json, f)
r = subprocess.run([binary, "--json", "--family", "auto", path],
capture_output=True, text=True)
diff --git a/Tools/linux-capture/hci_extract.py b/Tools/linux-capture/hci_extract.py
index 27f5414b70..4342ca4623 100644
--- a/Tools/linux-capture/hci_extract.py
+++ b/Tools/linux-capture/hci_extract.py
@@ -23,6 +23,7 @@
import struct
import sys
+from capture_io import configure_utf8_stdio
import whoop_frame as wf
# --- HCI log container parsing ---------------------------------------------------------------------
@@ -289,7 +290,7 @@ def main():
args = p.parse_args()
records, stats = extract(load_hci(args.input), args.family)
- with open(args.out, "w") as f:
+ with open(args.out, "w", encoding="utf-8") as f:
json.dump(records, f, indent=1)
if not records:
@@ -309,4 +310,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/pair_probe.py b/Tools/linux-capture/pair_probe.py
index dbc91b1ab5..7fd46da5a0 100644
--- a/Tools/linux-capture/pair_probe.py
+++ b/Tools/linux-capture/pair_probe.py
@@ -2,6 +2,7 @@
Usage: python3 pair_probe.py (or set WHOOP_MAC). Use on a strap you own; phone BT off."""
import asyncio, os, sys, time
from bleak import BleakClient, BleakScanner
+from capture_io import configure_utf8_stdio
MAC = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("WHOOP_MAC", "")
if not MAC:
sys.exit("usage: python3 pair_probe.py (or export WHOOP_MAC=AA:BB:CC:DD:EE:FF)")
@@ -50,4 +51,5 @@ async def main():
except Exception as e:
log(f"connect FAILED: {type(e).__name__}: {str(e)[:110]}")
+configure_utf8_stdio()
asyncio.run(main())
diff --git a/Tools/linux-capture/test_console_encoding.py b/Tools/linux-capture/test_console_encoding.py
new file mode 100644
index 0000000000..7bae241f11
--- /dev/null
+++ b/Tools/linux-capture/test_console_encoding.py
@@ -0,0 +1,62 @@
+"""Executable-path coverage for consoles that default to a legacy Windows encoding.
+
+The subprocesses run on Linux with strict cp1252 stdout/stderr. This reproduces the encoding
+constraint but is not a native Windows test.
+"""
+
+import os
+import sqlite3
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+HERE = Path(__file__).resolve().parent
+
+
+def _run_cp1252(script, *args):
+ env = os.environ.copy()
+ env["PYTHONIOENCODING"] = "cp1252:strict"
+ return subprocess.run(
+ [sys.executable, str(HERE / script), *map(str, args)],
+ cwd=HERE,
+ env=env,
+ capture_output=True,
+ timeout=10,
+ )
+
+
+class ConsoleEncodingTests(unittest.TestCase):
+ def test_analyze_v25_real_entrypoint_is_unicode_safe(self):
+ result = _run_cp1252("analyze_v25_waveform.py")
+ self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", errors="replace"))
+ self.assertIn("running the DEMO", result.stdout.decode("utf-8"))
+
+ def test_whoop_sync_real_entrypoint_is_unicode_safe(self):
+ with tempfile.TemporaryDirectory() as td:
+ db_path = os.path.join(td, "capture.db")
+ con = sqlite3.connect(db_path)
+ con.execute(
+ "CREATE TABLE devices (id INTEGER PRIMARY KEY, address TEXT NOT NULL UNIQUE, "
+ "name TEXT, subject TEXT, model TEXT, created_ms INTEGER)"
+ )
+ con.execute(
+ "CREATE TABLE frames (id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, "
+ "recv_ms INTEGER NOT NULL, char TEXT, inner_type INTEGER, unix INTEGER, "
+ "hr INTEGER, hex TEXT NOT NULL, UNIQUE(device_id, hex))"
+ )
+ con.execute(
+ "INSERT INTO devices(address, name, model) VALUES('AA:BB:CC:DD:EE:FF', 'strap', 'whoop4')"
+ )
+ con.commit()
+ con.close()
+
+ result = _run_cp1252("whoop_sync.py", "devices", "--db", db_path)
+ self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", errors="replace"))
+ self.assertIn("→", result.stdout.decode("utf-8"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/Tools/linux-capture/test_correlate_ground_truth.py b/Tools/linux-capture/test_correlate_ground_truth.py
index dafdb2f215..9c4b6eceda 100644
--- a/Tools/linux-capture/test_correlate_ground_truth.py
+++ b/Tools/linux-capture/test_correlate_ground_truth.py
@@ -61,7 +61,7 @@ def test_load_english_cycles_spo2(self):
"2026-07-14 23:51:24,97.29,34.40\n"
)
folder = tempfile.mkdtemp()
- with open(os.path.join(folder, "physiological_cycles.csv"), "w") as f:
+ with open(os.path.join(folder, "physiological_cycles.csv"), "w", encoding="utf-8") as f:
f.write(cycles)
rows = cg.load_ground_truth(folder)
truth = cg.truth_values(rows)
@@ -72,9 +72,9 @@ def test_merges_cycles_and_sleep_on_cycle_start(self):
cycles = "Startzeit des Zyklus,Herzfrequenzvariabilität (ms)\n2026-06-20 00:24:31,92\n"
sleep = "Startzeit des Zyklus,Atemfrequenz (Atemzüge/Min.)\n2026-06-20 00:24:31,14.6\n"
folder = tempfile.mkdtemp()
- with open(os.path.join(folder, "physiologische_zyklen.csv"), "w") as f:
+ with open(os.path.join(folder, "physiologische_zyklen.csv"), "w", encoding="utf-8") as f:
f.write(cycles)
- with open(os.path.join(folder, "Schlaf.csv"), "w") as f:
+ with open(os.path.join(folder, "Schlaf.csv"), "w", encoding="utf-8") as f:
f.write(sleep)
rows = cg.load_ground_truth(folder)
self.assertEqual(len(rows), 1)
diff --git a/Tools/linux-capture/test_decode_features.py b/Tools/linux-capture/test_decode_features.py
index 124b077a69..75b9af2122 100644
--- a/Tools/linux-capture/test_decode_features.py
+++ b/Tools/linux-capture/test_decode_features.py
@@ -9,27 +9,29 @@
import decode_features as df
-def _conn():
+def _conn(owner=None):
c = sqlite3.connect(":memory:")
df.apply_schema(c)
+ if owner is not None:
+ owner.addCleanup(c.close)
return c
class SchemaTests(unittest.TestCase):
def test_creates_four_feature_tables(self):
- c = _conn()
+ c = _conn(self)
names = {r[0] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
self.assertTrue({"feat_second", "feat_rr", "feat_ppg", "feat_event"} <= names)
def test_schema_is_idempotent(self):
- c = _conn()
+ c = _conn(self)
df.apply_schema(c) # second apply must not raise
self.assertTrue(True)
def test_feat_ppg_has_burst_index_column(self):
# PR#553: the raw per-burst counter column ships on a fresh feat_ppg.
- c = _conn()
+ c = _conn(self)
cols = {r[1] for r in c.execute("PRAGMA table_info(feat_ppg)")}
self.assertIn("burst_index", cols)
@@ -37,6 +39,7 @@ def test_burst_index_migration_is_idempotent_on_legacy_db(self):
# A pre-PR#553 DB has feat_ppg WITHOUT burst_index; apply_schema must ALTER it in once, and a
# second apply must be a no-op (never raise "duplicate column").
c = sqlite3.connect(":memory:")
+ self.addCleanup(c.close)
c.executescript(
"CREATE TABLE feat_ppg (device_id INTEGER NOT NULL, unix INTEGER NOT NULL, "
"sample_idx INTEGER NOT NULL, channel INTEGER NOT NULL, value INTEGER NOT NULL, "
@@ -67,9 +70,9 @@ def test_drops_nonpositive(self):
self.assertEqual(df.rr_stats([0, -5, 600]), (1, 600.0, None))
-def _rec(inner_type, parsed, unix=1000, version=24, crc_ok=True, device_id=1):
+def _rec(inner_type, parsed, unix=1000, version=24, ok=True, device_id=1):
return {"device_id": device_id, "unix": unix, "inner_type": inner_type,
- "version": version, "crc_ok": crc_ok, "parsed": parsed}
+ "version": version, "ok": ok, "parsed": parsed}
class FeatureToRowsTests(unittest.TestCase):
@@ -134,10 +137,23 @@ def test_event_numeric(self):
self.assertEqual(out["event"]["kind"], "123")
self.assertEqual(out["event"]["event_num"], 123)
- def test_crc_invalid_skipped(self):
- out = df.feature_to_rows(_rec(47, {"heart_rate": 95}, crc_ok=False))
+ def test_rejected_frame_skipped(self):
+ out = df.feature_to_rows(_rec(47, {"heart_rate": 95}, ok=False))
self.assertEqual(out, {"second": None, "rr": [], "ppg": [], "event": None})
+ def test_missing_verdict_is_rejected_not_passed(self):
+ # A record that carries no verdict at all yields no rows: absence is a rejection, never a pass.
+ rec = _rec(47, {"heart_rate": 95})
+ del rec["ok"]
+ self.assertEqual(df.feature_to_rows(rec),
+ {"second": None, "rr": [], "ppg": [], "event": None})
+
+ def test_rejected_event_frame_skipped(self):
+ # The verdict gates events too, not only the per-second data types.
+ out = df.feature_to_rows(_rec(48, {"event": "WRIST_ON", "event_timestamp": 1000},
+ unix=None, ok=False))
+ self.assertIsNone(out["event"])
+
def test_no_unix_non_event_skipped(self):
out = df.feature_to_rows(_rec(47, {"heart_rate": 95}, unix=None))
self.assertIsNone(out["second"])
@@ -149,7 +165,7 @@ def test_metadata_type_skipped(self):
class ApplyRowsTests(unittest.TestCase):
def test_inserts_all_tables(self):
- c = _conn()
+ c = _conn(self)
mapped = [
df.feature_to_rows(_rec(47, {"heart_rate": 95, "rr_intervals": [600, 610]})),
df.feature_to_rows(_rec(47, {"ppg_waveform": [1, 2], "burst_index": 3}, unix=1001, version=26)),
@@ -165,7 +181,7 @@ def test_inserts_all_tables(self):
self.assertEqual((ch, bi), (0, 3))
def test_idempotent(self):
- c = _conn()
+ c = _conn(self)
mapped = [df.feature_to_rows(_rec(47, {"heart_rate": 95, "rr_intervals": [600]}))]
df.apply_rows(c, 1, mapped)
df.apply_rows(c, 1, mapped) # second time changes nothing
@@ -173,7 +189,7 @@ def test_idempotent(self):
self.assertEqual(c.execute("SELECT COUNT(*) FROM feat_rr").fetchone()[0], 1)
def test_coalesce_merges_partial_seconds(self):
- c = _conn()
+ c = _conn(self)
# scalar-only second, then ppg-only at SAME unix must not clobber hr.
df.apply_rows(c, 1, [df.feature_to_rows(_rec(47, {"heart_rate": 88}, unix=1000))])
df.apply_rows(c, 1, [df.feature_to_rows(_rec(47, {"gravity_x": 0.5}, unix=1000, version=18))])
@@ -182,9 +198,9 @@ def test_coalesce_merges_partial_seconds(self):
self.assertEqual(row[1], 0.5) # merged in
-def _decoded(fields, crcOK=True, family="whoop5"):
+def _decoded(fields, crcOK=True, family="whoop5", ok=True):
return {"family": family, "char": "x", "ts_ms": 0, "hr": None,
- "frame": {"crcOK": crcOK, "seq": 1, "fields": fields}}
+ "frame": {"ok": ok, "crcOK": crcOK, "seq": 1, "fields": fields}}
def _fld(name, value, cat):
@@ -208,12 +224,13 @@ def test_flattens_fields_dropping_envelope_and_raw(self):
self.assertEqual(rec["unix"], 1700000000)
self.assertEqual(rec["inner_type"], 47)
self.assertEqual(rec["version"], 18)
- self.assertTrue(rec["crc_ok"])
+ self.assertTrue(rec["ok"])
def test_carries_rr_intervals_from_parsed(self):
# The R-R array is parsed-only (per-interval values are rr[i] FIELDS, the array is not a field),
# so normalize must carry frame.parsed["rr_intervals"] through or feat_rr/rmssd stay empty.
- raw = {"family": "whoop5", "frame": {"crcOK": True, "fields": [_fld("rr_count", 2, "rr")],
+ raw = {"family": "whoop5", "frame": {"ok": True, "crcOK": True,
+ "fields": [_fld("rr_count", 2, "rr")],
"parsed": {"rr_intervals": [602, 613]}}}
src = (1, "aa", "fd4b0005", 0, 1700000000, None, 47)
rec = df.normalize_decode_record(raw, src, 1)
@@ -222,7 +239,7 @@ def test_carries_rr_intervals_from_parsed(self):
def test_realtime_type40_timestamp_supplies_unix_and_rr(self):
# Realtime (type-40) frames have no frames.unix; the decoded `timestamp` field supplies it, and
# the carried rr_intervals must flow into feat_rr (the realtime-capture path).
- raw = {"family": "whoop5", "frame": {"crcOK": True,
+ raw = {"family": "whoop5", "frame": {"ok": True, "crcOK": True,
"fields": [_fld("timestamp", 1781084150, "time"), _fld("rr_count", 1, "rr")],
"parsed": {"timestamp": 1781084150, "rr_intervals": [963]}}}
src = (1, "aa", "fd4b0005", 1781084150307, None, None, 40) # frames.unix = None
@@ -230,10 +247,21 @@ def test_realtime_type40_timestamp_supplies_unix_and_rr(self):
self.assertEqual(m["second"]["unix"], 1781084150)
self.assertEqual(m["rr"], [{"unix": 1781084150, "idx": 0, "rr_ms": 963}])
- def test_crcOK_false(self):
- raw = _decoded([_fld("heart_rate", 90, "hr")], crcOK=False)
+ def test_verdict_false(self):
+ raw = _decoded([_fld("heart_rate", 90, "hr")], crcOK=False, ok=False)
+ src = (1, "aa", "c", 0, 100, None, 47)
+ self.assertFalse(df.normalize_decode_record(raw, src, 1)["ok"])
+
+ def test_payload_crc_ok_but_envelope_rejected(self):
+ # The class this gate is for: the payload CRC32 verifies, but the decoder's full verdict is
+ # negative (broken header checksum or a length that does not add up). `crcOK` alone would
+ # have let the frame through.
+ raw = _decoded([_fld("heart_rate", 90, "hr")], crcOK=True, ok=False)
src = (1, "aa", "c", 0, 100, None, 47)
- self.assertFalse(df.normalize_decode_record(raw, src, 1)["crc_ok"])
+ rec = df.normalize_decode_record(raw, src, 1)
+ self.assertFalse(rec["ok"])
+ self.assertEqual(df.feature_to_rows(rec),
+ {"second": None, "rr": [], "ppg": [], "event": None})
def test_whoop4_version_defaults_to_24(self):
raw = _decoded([_fld("heart_rate", 110, "hr")], family="whoop4") # no hist_version field
@@ -254,18 +282,24 @@ def test_unix_falls_back_to_source_when_decoder_lacks_it(self):
src = (1, "aa", "c", 0, 555, None, 48)
self.assertEqual(df.normalize_decode_record(raw, src, 1)["unix"], 555)
- def test_missing_frame_is_empty_parsed_crc_true(self):
+ def test_missing_frame_is_empty_parsed_and_rejected(self):
src = (1, "aa", "c", 0, 100, None, 47)
rec = df.normalize_decode_record({}, src, 1)
self.assertEqual(rec["parsed"], {})
- self.assertTrue(rec["crc_ok"])
+ self.assertFalse(rec["ok"])
self.assertIsNone(rec["version"])
+ def test_missing_verdict_field_is_rejected(self):
+ # An object whose frame carries no `ok` key at all: fail closed, do not assume a pass.
+ raw = {"family": "whoop5", "frame": {"crcOK": True, "fields": [_fld("heart_rate", 90, "hr")]}}
+ src = (1, "aa", "c", 0, 100, None, 47)
+ self.assertFalse(df.normalize_decode_record(raw, src, 1)["ok"])
+
-def _dec_obj(fields, family="whoop4", crcOK=True):
+def _dec_obj(fields, family="whoop4", crcOK=True, ok=True):
"""A whoop-decode --json object stub (real shape: values live in frame.fields)."""
return {"family": family, "char": "x", "ts_ms": 0, "hr": None,
- "frame": {"crcOK": crcOK, "seq": 1, "fields": fields}}
+ "frame": {"ok": ok, "crcOK": crcOK, "seq": 1, "fields": fields}}
class _FakeDB:
@@ -286,6 +320,11 @@ def frames_after(self, did, after_id):
class DecodeNewTests(unittest.TestCase):
+ def _db(self):
+ db = _FakeDB(self._frames())
+ self.addCleanup(db.db.close)
+ return db
+
def _frames(self):
return [
(1, "aa", "6108", 10, 1000, 95, 47),
@@ -302,7 +341,7 @@ def _decoder(self, records):
]
def test_decodes_and_advances_cursor(self):
- fdb = _FakeDB(self._frames())
+ fdb = self._db()
res = df.decode_new(fdb, 1, decode_fn=self._decoder)
self.assertEqual(res["frames"], 3)
self.assertEqual(res["skipped"], 1) # the inner_type-49 frame
@@ -310,19 +349,33 @@ def test_decodes_and_advances_cursor(self):
self.assertEqual(fdb.state(1)["last_decoded_frame_id"], "3")
def test_incremental_second_run_is_noop(self):
- fdb = _FakeDB(self._frames())
+ fdb = self._db()
df.decode_new(fdb, 1, decode_fn=self._decoder)
res2 = df.decode_new(fdb, 1, decode_fn=self._decoder)
self.assertEqual(res2["frames"], 0) # cursor past all frames
def test_full_redecodes_from_zero(self):
- fdb = _FakeDB(self._frames())
+ fdb = self._db()
df.decode_new(fdb, 1, decode_fn=self._decoder)
res = df.decode_new(fdb, 1, full=True, decode_fn=self._decoder)
self.assertEqual(res["frames"], 3) # full ignores the cursor
+ def test_rejected_frame_contributes_no_rows(self):
+ # End to end over the whole stage: a frame the decoder rejected (payload CRC fine, envelope
+ # not) writes nothing and counts as skipped, while its intact neighbour still lands.
+ fdb = self._db()
+ decoder = lambda records: [
+ _dec_obj([_fld("heart_rate", 95, "hr")], crcOK=True, ok=False),
+ _dec_obj([_fld("heart_rate", 96, "hr")]),
+ _dec_obj([_fld("meta_type", 3, "meta")]),
+ ]
+ res = df.decode_new(fdb, 1, decode_fn=decoder)
+ self.assertEqual(res["skipped"], 2) # the rejected frame + the inner_type-49 one
+ rows = fdb.db.execute("SELECT unix, hr FROM feat_second").fetchall()
+ self.assertEqual(rows, [(1001, 96)])
+
def test_decoder_count_mismatch_raises(self):
- fdb = _FakeDB(self._frames()) # 3 frames
+ fdb = self._db() # 3 frames
short = lambda records: [_dec_obj([_fld("heart_rate", 95, "hr")])] # returns only 1
with self.assertRaises(RuntimeError):
df.decode_new(fdb, 1, decode_fn=short)
diff --git a/Tools/linux-capture/test_validate_spo2_candidate.py b/Tools/linux-capture/test_validate_spo2_candidate.py
index 155eaff60d..5e8bcf1113 100644
--- a/Tools/linux-capture/test_validate_spo2_candidate.py
+++ b/Tools/linux-capture/test_validate_spo2_candidate.py
@@ -6,18 +6,65 @@
from __future__ import annotations
+import builtins
+import io
import json
import os
import sqlite3
import struct
import tempfile
import unittest
+from unittest import mock
from datetime import datetime, timezone
import validate_spo2_candidate as vs
from test_whoop_activity import make_v18
+class EncodingPortabilityTests(unittest.TestCase):
+ """Exercise Windows-like non-UTF-8 defaults without requiring a Windows host."""
+
+ def test_capture_json_is_read_as_utf8_under_cp1252_default(self):
+ with tempfile.TemporaryDirectory() as td:
+ path = os.path.join(td, "capture.json")
+ expected = [{"hex": "aa", "device": "München"}]
+ with builtins.open(path, "w", encoding="utf-8") as f:
+ json.dump(expected, f, ensure_ascii=False)
+
+ real_open = builtins.open
+
+ def cp1252_default_open(*args, **kwargs):
+ mode = args[1] if len(args) > 1 else kwargs.get("mode", "r")
+ if "b" not in mode and "encoding" not in kwargs:
+ kwargs["encoding"] = "cp1252"
+ return real_open(*args, **kwargs)
+
+ with mock.patch("builtins.open", side_effect=cp1252_default_open):
+ self.assertEqual(vs.load_frame_records(path), expected)
+
+ def test_postable_summary_prints_to_cp1252_console(self):
+ result = {
+ "device": "strap-a",
+ "classification": "feature_absent",
+ "paired_nights": 0,
+ "r": None,
+ "mae": None,
+ "bias": None,
+ "best_specificity_offset": None,
+ "duty": None,
+ "median_window_coverage": None,
+ "checklist": {"pass": False},
+ }
+ raw = io.BytesIO()
+ console = io.TextIOWrapper(raw, encoding="cp1252")
+ with mock.patch.object(vs.sys, "stdout", console):
+ vs.configure_utf8_stdio()
+ print(vs.format_postable([result]))
+ console.flush()
+ self.assertEqual(console.encoding, "utf-8")
+ self.assertIn("need ≥5 paired nights".encode(), raw.getvalue())
+
+
def _utc(y, m, d, hh=0, mm=0, ss=0) -> int:
return int(datetime(y, m, d, hh, mm, ss, tzinfo=timezone.utc).timestamp())
@@ -102,7 +149,7 @@ def _write_export(folder: str, nights: list[tuple[str, float, int, int]]) -> str
s0 = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
s1 = datetime.fromtimestamp(t1, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
lines.append(f"{start},,{spo2:.2f},{s0},{s1}\n")
- with open(path, "w") as f:
+ with open(path, "w", encoding="utf-8") as f:
f.writelines(lines)
return folder
@@ -136,7 +183,7 @@ def test_recovers_perfect_correlation_across_nights(self):
with tempfile.TemporaryDirectory() as td:
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp = _write_export(td, exports)
res = vs.validate_device(cap, exp, device="planted-a")
@@ -154,11 +201,11 @@ def test_incomplete_nights_without_export_spo2_are_skipped(self):
frames = _plant_night(t0, t1, 97)
with tempfile.TemporaryDirectory() as td:
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
# Export row with empty SpO₂ — must not pair.
path = os.path.join(td, "physiological_cycles.csv")
- with open(path, "w") as f:
+ with open(path, "w", encoding="utf-8") as f:
f.write(
"Cycle start time,Blood oxygen %,Sleep onset,Wake onset\n"
"2026-06-10 23:00:00,,"
@@ -182,7 +229,7 @@ def test_awake_samples_do_not_contribute(self):
rec["hex"] = bytes(f).hex()
with tempfile.TemporaryDirectory() as td:
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
exp = _write_export(td, [(start, 97.0, t0, t1)])
@@ -204,7 +251,7 @@ def test_offset_specificity_prefers_true_offset(self):
exports.append((start, float(spo2), t0, t1))
with tempfile.TemporaryDirectory() as td:
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp = _write_export(td, exports)
res = vs.validate_device(cap, exp, device="spec")
@@ -222,7 +269,7 @@ def test_postable_summary_has_no_raw_values(self):
exports.append((start, float(spo2), t0, t1))
with tempfile.TemporaryDirectory() as td:
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp = _write_export(td, exports)
res = vs.validate_device(cap, exp, device="post")
@@ -248,7 +295,7 @@ def make_device(td, label, spo2s):
)
exports.append((start, float(spo2), t0, t1))
cap = os.path.join(td, f"{label}.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp_dir = os.path.join(td, label)
os.makedirs(exp_dir)
@@ -259,7 +306,7 @@ def make_device(td, label, spo2s):
a = make_device(td, "strap-a", [95, 96, 97, 98, 94, 99])
b = make_device(td, "strap-b", [93, 94, 95, 96, 97, 98])
batch = os.path.join(td, "devices.json")
- with open(batch, "w") as f:
+ with open(batch, "w", encoding="utf-8") as f:
json.dump([a, b], f)
rc = vs.main(["--batch", batch, "--postable"])
self.assertEqual(rc, 0)
@@ -307,7 +354,7 @@ def test_detects_sqlite_by_header_not_extension(self):
def test_json_captures_still_load_unchanged(self):
path = tempfile.mktemp(suffix=".json")
- with open(path, "w") as f:
+ with open(path, "w", encoding="utf-8") as f:
json.dump([{"hex": make_v18(sleep_state=2, aux_byte_82=94).hex()}], f)
self.addCleanup(lambda: os.path.exists(path) and os.unlink(path))
self.assertFalse(vs.looks_like_sqlite(path))
@@ -471,7 +518,7 @@ def test_coverage_is_reported_per_night_and_low_nights_are_flagged(self):
frames = [f for f in self.frames if _unix_of(f) < self.t0 + 3600]
with tempfile.TemporaryDirectory() as td:
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as fh:
+ with open(cap, "w", encoding="utf-8") as fh:
json.dump(frames, fh)
start = datetime.fromtimestamp(self.t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
exp = _write_export(td, [(start, 96.0, self.t0, self.t1)])
@@ -534,7 +581,7 @@ def _device(self, td, *, spo2s, neighbor_offset, neighbor_values, jitter):
start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
exports.append((start, float(spo2), t0, t1))
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
return cap, _write_export(td, exports)
@@ -568,7 +615,7 @@ def test_the_floor_also_gates_82_itself(self):
exports.append((start, float(spo2), t0, t1))
with tempfile.TemporaryDirectory() as td:
cap = os.path.join(td, "capture.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
res = vs.validate_device(cap, _write_export(td, exports), device="flat82")
self.assertEqual(res["inband_distinct_82"], 2)
@@ -601,7 +648,7 @@ def _flat_zero_device(self, td, label, *, n_records=851, step_s=5):
for i in range(n_records)
]
cap = os.path.join(td, f"{label}.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp_dir = os.path.join(td, label)
os.makedirs(exp_dir)
@@ -639,7 +686,7 @@ def passing(td, label, spo2s):
start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
exports.append((start, float(spo2), t0, t1))
cap = os.path.join(td, f"{label}.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp_dir = os.path.join(td, label)
os.makedirs(exp_dir)
@@ -692,7 +739,7 @@ def test_an_aliased_capture_of_a_WORKING_strap_is_not_reported_absent(self):
start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
nights.append((start, 95.0, t0, t1))
cap = os.path.join(td, "aliased.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
res = vs.validate_device(cap, _write_export(td, nights), device="aliased")
self.assertEqual(res["duty"]["n_nonzero"], 0) # the strap works; the capture missed it
@@ -710,7 +757,7 @@ def test_a_dense_burst_plus_one_far_later_record_cannot_claim_absence(self):
{"hex": make_v18(unix=u, sleep_state=2, hr=52, aux_byte_82=0).hex()} for u in stamps
]
cap = os.path.join(td, "sparse.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp_dir = os.path.join(td, "sparse")
os.makedirs(exp_dir)
@@ -727,7 +774,7 @@ def _asleep_device(self, td, label, stamps):
{"hex": make_v18(unix=u, sleep_state=st, hr=52, aux_byte_82=0).hex()} for u, st in stamps
]
cap = os.path.join(td, f"{label}.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
exp_dir = os.path.join(td, label)
os.makedirs(exp_dir)
@@ -789,7 +836,7 @@ def test_postable_still_carries_no_raw_spo2_values(self):
start = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
nights.append((start, float(s), t0, t1))
cap = os.path.join(td, "vals.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
res = vs.validate_device(cap, _write_export(td, nights), device="vals")
blob = vs.format_postable([res])
@@ -886,7 +933,7 @@ def test_app_store_is_told_apart_from_a_capture_store(self):
app = _write_app_db(td, [(1700000000, 96, 2)])
self.assertTrue(vs.looks_like_app_db(app))
cap = os.path.join(td, "cap.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump([], f)
self.assertFalse(vs.looks_like_app_db(cap))
@@ -975,6 +1022,6 @@ def _six_night_capture(self, td: str) -> dict:
frames.append({"hex": make_v18(t0 + k * 60, aux_byte_82=int(round(spo2)),
sleep_state=vs.SLEEP_ASLEEP).hex()})
cap = os.path.join(td, "cap.json")
- with open(cap, "w") as f:
+ with open(cap, "w", encoding="utf-8") as f:
json.dump(frames, f)
return {"capture": cap, "export": _write_export(td, nights)}
diff --git a/Tools/linux-capture/test_whoop_frame.py b/Tools/linux-capture/test_whoop_frame.py
index dfad726f85..4763efbb10 100644
--- a/Tools/linux-capture/test_whoop_frame.py
+++ b/Tools/linux-capture/test_whoop_frame.py
@@ -160,6 +160,71 @@ def test_build_history_ack_whoop4_shape_and_crc(self):
self.assertTrue(wf.verify_whoop4_frame(ack)) # ack is itself CRC-valid
+def _whoop4_frame_of_total(total: int) -> bytes:
+ """A structurally consistent WHOOP 4.0 frame of exactly `total` bytes: the declared length agrees
+ with the byte count, the CRC8 header checks out and the CRC32 trailer covers the inner bytes that
+ are left (none at all for the 8-byte case)."""
+ length = total - 4
+ len_bytes = bytes([length & 0xFF, (length >> 8) & 0xFF])
+ inner = (bytes([wf.COMMAND_TYPE, 0, wf.CMD_GET_BATTERY_LEVEL]) + bytes(8))[:total - 8]
+ trailer = wf.crc32(inner).to_bytes(4, "little")
+ return bytes([0xAA]) + len_bytes + bytes([wf.crc8(len_bytes)]) + inner + trailer
+
+
+def _whoop5_frame_of_total(total: int) -> bytes:
+ """The WHOOP 5.0/MG image of `_whoop4_frame_of_total`: declared length, CRC16 header and CRC32
+ trailer are all internally consistent, so only the size distinguishes the cases."""
+ decl = total - 8
+ head = bytes([0xAA, 0x01, decl & 0xFF, (decl >> 8) & 0xFF, 0x00, 0x00])
+ payload = (bytes([wf.COMMAND_TYPE, 0, 1, 0]) + bytes(8))[:total - 12]
+ return (head + wf.crc16_modbus(head).to_bytes(4, "little")[:2] + payload
+ + wf.crc32(payload).to_bytes(4, "little"))
+
+
+class MinimumFrameLengthTests(unittest.TestCase):
+ """The family minimums from WhoopProtocol's `FrameLimits` (11 bytes on WHOOP 4.0, 13 on 5.0/MG).
+
+ A frame below them is internally consistent — declared length, header checksum and a CRC32 over
+ zero payload bytes all agree — and was accepted before. It is rejected here so the verifiers of
+ this tool and of the protocol package answer the same question; the callers on the trim-ack path
+ already demand more bytes than this, so the effect is on the verifiers' other callers.
+ """
+
+ def test_whoop4_eight_byte_frame_is_rejected(self):
+ frame = _whoop4_frame_of_total(8)
+ self.assertEqual(len(frame), 8)
+ self.assertEqual(frame[3], wf.crc8(frame[1:3])) # header checksum is correct
+ self.assertEqual(frame[4:8], wf.crc32(b"").to_bytes(4, "little")) # CRC32 over no payload
+ self.assertFalse(wf.verify_whoop4_frame(frame))
+
+ def test_whoop4_below_minimum_is_rejected(self):
+ for total in (8, 9, 10):
+ with self.subTest(total=total):
+ self.assertFalse(wf.verify_whoop4_frame(_whoop4_frame_of_total(total)))
+
+ def test_whoop4_at_minimum_is_accepted(self):
+ frame = _whoop4_frame_of_total(11)
+ self.assertEqual(len(frame), 11)
+ self.assertTrue(wf.verify_whoop4_frame(frame))
+
+ def test_whoop5_twelve_byte_frame_is_rejected(self):
+ frame = _whoop5_frame_of_total(12)
+ self.assertEqual(len(frame), 12)
+ self.assertEqual(frame[6] | (frame[7] << 8), wf.crc16_modbus(frame[0:6]))
+ self.assertEqual(frame[8:12], wf.crc32(b"").to_bytes(4, "little"))
+ self.assertFalse(wf.verify_whoop5_frame(frame))
+
+ def test_whoop5_at_minimum_is_accepted(self):
+ frame = _whoop5_frame_of_total(13)
+ self.assertEqual(len(frame), 13)
+ self.assertTrue(wf.verify_whoop5_frame(frame))
+
+ def test_real_frames_stay_valid(self):
+ # The recorded frames are far above both bounds; raising the minimum must not touch them.
+ self.assertTrue(wf.verify_whoop5_frame(HistoryAckTests.HISTORY_END))
+ self.assertTrue(wf.verify_whoop4_frame(Whoop4HistoryAckTests.HISTORY_END))
+
+
class ReassemblerTests(unittest.TestCase):
def test_single_frame_across_fragments(self):
hello = wf.WHOOP5_CLIENT_HELLO
diff --git a/Tools/linux-capture/test_whoop_spot_hrv.py b/Tools/linux-capture/test_whoop_spot_hrv.py
index f756558a10..f5b1bf4100 100644
--- a/Tools/linux-capture/test_whoop_spot_hrv.py
+++ b/Tools/linux-capture/test_whoop_spot_hrv.py
@@ -5,10 +5,99 @@
README documents and the only one in requirements.txt) or pytest.
"""
import math
+import os
+import sqlite3
+import sys
+import tempfile
+from unittest import mock
import whoop_spot_hrv as H
+def _empty_ppg_db():
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ con = sqlite3.connect(path)
+ con.execute(
+ "CREATE TABLE feat_ppg (device_id INTEGER, unix INTEGER, sample_idx INTEGER, "
+ "channel INTEGER, value INTEGER)"
+ )
+ con.close()
+ return path
+
+
+def _assert_connection_closed(con):
+ try:
+ con.execute("SELECT 1")
+ except sqlite3.ProgrammingError:
+ return
+ raise AssertionError("SQLite connection remained open")
+
+
+def _run_main_tracking_connection(path, *extra_args):
+ opened = []
+ real_connect = sqlite3.connect
+
+ def tracking_connect(*args, **kwargs):
+ con = real_connect(*args, **kwargs)
+ opened.append(con)
+ return con
+
+ argv = ["whoop_spot_hrv.py", "--db", path, "--device", "1", *extra_args]
+ with mock.patch.object(H.sqlite3, "connect", side_effect=tracking_connect), \
+ mock.patch.object(sys, "argv", argv):
+ result = H.main()
+ assert len(opened) == 1
+ return result, opened[0]
+
+
+def test_main_closes_database_on_normal_path():
+ path = _empty_ppg_db()
+ try:
+ result, con = _run_main_tracking_connection(path, "--start", "1", "--end", "2")
+ assert result == 0
+ _assert_connection_closed(con)
+ finally:
+ os.remove(path)
+
+
+def test_main_closes_database_on_no_windows_early_return():
+ path = _empty_ppg_db()
+ try:
+ result, con = _run_main_tracking_connection(path)
+ assert result == 1
+ _assert_connection_closed(con)
+ finally:
+ os.remove(path)
+
+
+def test_main_closes_database_when_query_raises():
+ path = _empty_ppg_db()
+ opened = []
+ real_connect = sqlite3.connect
+
+ def tracking_connect(*args, **kwargs):
+ con = real_connect(*args, **kwargs)
+ opened.append(con)
+ return con
+
+ argv = ["whoop_spot_hrv.py", "--db", path, "--device", "1"]
+ try:
+ with mock.patch.object(H.sqlite3, "connect", side_effect=tracking_connect), \
+ mock.patch.object(H, "_covered_windows", side_effect=RuntimeError("query failed")), \
+ mock.patch.object(sys, "argv", argv):
+ try:
+ H.main()
+ except RuntimeError as exc:
+ assert str(exc) == "query failed"
+ else:
+ raise AssertionError("expected query failure")
+ assert len(opened) == 1
+ _assert_connection_closed(opened[0])
+ finally:
+ os.remove(path)
+
+
def _synth_ppg(fs, n_beats, ibi_ms, width_s=0.12):
"""A clean PPG-like waveform: a Gaussian pulse at each beat time given an IBI sequence."""
beat_t = [0.5]
diff --git a/Tools/linux-capture/test_whoop_sync.py b/Tools/linux-capture/test_whoop_sync.py
index 3b41d52388..f9d6d999d1 100644
--- a/Tools/linux-capture/test_whoop_sync.py
+++ b/Tools/linux-capture/test_whoop_sync.py
@@ -8,9 +8,15 @@
verified whoop5 offsets minus 4, and match the on-hardware HISTORY_END the strap serves.
"""
+import asyncio
import os
+import sqlite3
+import sys
import tempfile
+import threading
import unittest
+from contextlib import suppress
+from unittest import mock
import whoop_frame as wf
import whoop_sync as ws
@@ -98,6 +104,89 @@ def setUp(self):
self.tmp = tempfile.mkdtemp()
self.db = ws.WhoopDB(os.path.join(self.tmp, "t.db"))
+ def tearDown(self):
+ self.db.close()
+
+ def test_context_manager_closes_on_normal_exit(self):
+ path = os.path.join(self.tmp, "normal.db")
+ with ws.WhoopDB(path) as db:
+ con = db.db
+ con.execute("SELECT 1")
+ with self.assertRaises(sqlite3.ProgrammingError):
+ con.execute("SELECT 1")
+
+ def test_context_manager_closes_on_exception(self):
+ path = os.path.join(self.tmp, "error.db")
+ with self.assertRaisesRegex(RuntimeError, "boom"):
+ with ws.WhoopDB(path) as db:
+ con = db.db
+ raise RuntimeError("boom")
+ with self.assertRaises(sqlite3.ProgrammingError):
+ con.execute("SELECT 1")
+
+ def test_constructor_closes_if_schema_initialization_fails(self):
+ opened = []
+ real_connect = sqlite3.connect
+
+ def tracking_connect(*args, **kwargs):
+ con = real_connect(*args, **kwargs)
+ opened.append(con)
+ return con
+
+ path = os.path.join(self.tmp, "init-error.db")
+ with mock.patch.object(ws.sqlite3, "connect", side_effect=tracking_connect), \
+ mock.patch.object(ws.decode_features, "apply_schema", side_effect=RuntimeError("boom")):
+ with self.assertRaisesRegex(RuntimeError, "boom"):
+ ws.WhoopDB(path)
+ self.assertEqual(len(opened), 1)
+ with self.assertRaises(sqlite3.ProgrammingError):
+ opened[0].execute("SELECT 1")
+
+ def test_database_outlives_cancelled_thread_commit(self):
+ """A cancelled to_thread await must not close SQLite while its worker still commits."""
+ started = threading.Event()
+ release = threading.Event()
+ errors = []
+ db_path = os.path.join(self.tmp, "cancelled-commit.db")
+ real_close = ws.WhoopDB.close
+
+ def close_then_release(db):
+ real_close(db)
+ release.set()
+
+ async def candidate_run(args, db):
+ did = db.upsert_device("AA:BB:CC:DD:EE:FF", model="whoop4")
+ frame = (1, "61080005", 47, 1000, 60, "aa01")
+
+ def delayed_commit():
+ started.set()
+ release.wait(timeout=0.3)
+ try:
+ db.commit_chunk(did, [frame], trim=1, complete=False)
+ except Exception as exc:
+ errors.append(exc)
+
+ task = asyncio.create_task(asyncio.to_thread(delayed_commit))
+
+ async def wait_until_started():
+ while not started.is_set():
+ await asyncio.sleep(0.005)
+
+ await asyncio.wait_for(wait_until_started(), timeout=1.0)
+ task.cancel()
+ with suppress(asyncio.CancelledError):
+ await task
+
+ argv = ["whoop_sync.py", "sync", "--address", "AA:BB:CC:DD:EE:FF", "--db", db_path]
+ try:
+ with mock.patch.object(ws, "_run", side_effect=candidate_run), \
+ mock.patch.object(ws.WhoopDB, "close", autospec=True, side_effect=close_then_release), \
+ mock.patch.object(sys, "argv", argv):
+ ws.main()
+ finally:
+ release.set()
+ self.assertEqual(errors, [])
+
def _frame(self, hexstr, t=47, unix=1000, hr=60):
return (1, "61080005", t, unix, hr, hexstr) # (recv_ms, char, inner_type, unix, hr, hex)
@@ -138,7 +227,7 @@ def test_export_json_roundtrip_and_filter(self):
out = os.path.join(self.tmp, "o.json")
n = self.db.export_json(did, out, only_type=47)
self.assertEqual(n, 1)
- with open(out) as f:
+ with open(out, encoding="utf-8") as f:
recs = json.load(f)
self.assertEqual(recs[0]["hex"], "aa01")
@@ -157,6 +246,9 @@ def setUp(self):
self.tmp = tempfile.mkdtemp()
self.db = ws.WhoopDB(os.path.join(self.tmp, "t.db"))
+ def tearDown(self):
+ self.db.close()
+
def _frame(self, hexstr, t=47, unix=1000, hr=60):
return (1, "61080005", t, unix, hr, hexstr) # (recv_ms, char, inner_type, unix, hr, hex)
diff --git a/Tools/linux-capture/validate_spo2_candidate.py b/Tools/linux-capture/validate_spo2_candidate.py
index 5a843831d0..2768af0be1 100644
--- a/Tools/linux-capture/validate_spo2_candidate.py
+++ b/Tools/linux-capture/validate_spo2_candidate.py
@@ -81,6 +81,7 @@
from datetime import datetime, timezone
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
+from capture_io import configure_utf8_stdio
import whoop_activity as wa
import whoop_frame as wf
@@ -151,6 +152,7 @@
MIN_DISTINCT_INBAND = 5
MIN_INBAND_STDEV = 0.5
+
# English + localized cycle headers → canonical keys (mirrors StrandImport HeaderNorm subset).
HEADER_ALIASES = {
# English (official export; lowercase after norm)
@@ -467,7 +469,7 @@ def load_frame_records(path: str, *, device_id: int = 2) -> List[dict]:
)
return [{"hex": r[0]} for r in rows]
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
capture = json.load(f)
if not isinstance(capture, list):
raise SystemExit(f"{path}: expected a JSON list of frame records, or a whoop_sync.py .db")
@@ -1151,6 +1153,7 @@ def format_postable(results: Sequence[dict]) -> str:
# --- CLI -------------------------------------------------------------------------------------------
def main(argv: Optional[Sequence[str]] = None) -> int:
+ configure_utf8_stdio()
p = argparse.ArgumentParser(
description="Validate WHOOP 5/MG v18 spo2_candidate_82 against a CSV export (#103)."
)
@@ -1205,7 +1208,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
results: List[dict] = []
if args.batch:
- with open(args.batch) as f:
+ with open(args.batch, encoding="utf-8") as f:
batch = json.load(f)
if not isinstance(batch, list) or not batch:
print("batch file must be a non-empty JSON list", file=sys.stderr)
diff --git a/Tools/linux-capture/whoop_activity.py b/Tools/linux-capture/whoop_activity.py
index c32caaadd6..f2b7f611ef 100644
--- a/Tools/linux-capture/whoop_activity.py
+++ b/Tools/linux-capture/whoop_activity.py
@@ -151,26 +151,30 @@ def sleep_state_minutes(recs):
import json
import datetime as _dt
+from capture_io import configure_utf8_stdio
+
DEFAULT_DB = "captures/whoop4.db"
def records(db_path=DEFAULT_DB, device_id=2, start=None, end=None):
"""Decoded v18 records (time-ordered) from the frames table."""
con = sqlite3.connect(db_path)
- q = "SELECT hex FROM frames WHERE device_id=? AND inner_type=47"
- args = [device_id]
- if start is not None:
- q += " AND unix>=?"; args.append(start)
- if end is not None:
- q += " AND unix"; args.append(end)
- q += " ORDER BY unix"
- out = []
- for (hx,) in con.execute(q, args):
- d = decode_v18(bytes.fromhex(hx))
- if d is not None:
- out.append(d)
- con.close()
- return out
+ try:
+ q = "SELECT hex FROM frames WHERE device_id=? AND inner_type=47"
+ args = [device_id]
+ if start is not None:
+ q += " AND unix>=?"; args.append(start)
+ if end is not None:
+ q += " AND unix"; args.append(end)
+ q += " ORDER BY unix"
+ out = []
+ for (hx,) in con.execute(q, args):
+ d = decode_v18(bytes.fromhex(hx))
+ if d is not None:
+ out.append(d)
+ return out
+ finally:
+ con.close()
def _day_bounds(day):
@@ -233,4 +237,5 @@ def main(argv=None):
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/whoop_buzz.py b/Tools/linux-capture/whoop_buzz.py
index 340dbe1b1b..9b769e081e 100644
--- a/Tools/linux-capture/whoop_buzz.py
+++ b/Tools/linux-capture/whoop_buzz.py
@@ -40,6 +40,7 @@
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
+from capture_io import configure_utf8_stdio
import whoop_frame as wf
log = logging.getLogger("whoop_buzz")
@@ -294,4 +295,5 @@ def main() -> int:
if __name__ == "__main__":
+ configure_utf8_stdio()
sys.exit(main())
diff --git a/Tools/linux-capture/whoop_capture.py b/Tools/linux-capture/whoop_capture.py
index 976d2d65d5..e48240fe23 100644
--- a/Tools/linux-capture/whoop_capture.py
+++ b/Tools/linux-capture/whoop_capture.py
@@ -29,6 +29,7 @@
from bleak import BleakClient, BleakScanner
+from capture_io import configure_utf8_stdio
import whoop_frame as wf
# --- GATT UUIDs (from docs/BLE_REVERSE_ENGINEERING.md) ---------------------------------------------
@@ -146,7 +147,7 @@ def flush(self):
if not self.records:
return
tmp = self.out_path + ".tmp"
- with open(tmp, "w") as f:
+ with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.records, f, indent=1)
import os
os.replace(tmp, self.out_path)
@@ -496,4 +497,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/whoop_frame.py b/Tools/linux-capture/whoop_frame.py
index 91d50a2244..a9b40671c1 100644
--- a/Tools/linux-capture/whoop_frame.py
+++ b/Tools/linux-capture/whoop_frame.py
@@ -263,14 +263,26 @@ def command_response_cmd(frame: bytes, family: str):
WHOOP5_META_TRIM_OFF = 21
WHOOP5_END_DATA_LEN = 8
+# Smallest well-formed frame per family, mirroring `FrameLimits` in the Swift WhoopProtocol package
+# (whoop4MinimumFrameBytes = 11, whoop5MinimumFrameBytes = 13). Below these, a byte run can still be
+# internally consistent while carrying next to no payload — at the smallest size its CRC32 covers no
+# payload byte at all — and both verifiers here used to accept it.
+#
+# On the trim-ack path itself this bound is not reachable: `history_end_data*` already requires 25 and
+# 29 bytes before it verifies anything. The bound is here so this tool's verifiers answer the same
+# question as the protocol package's, for their other callers.
+WHOOP4_MIN_FRAME_BYTES = 11
+WHOOP5_MIN_FRAME_BYTES = 13
+
def verify_whoop5_frame(frame: bytes) -> bool:
- """True if a WHOOP 5.0 frame's declared length, CRC16 header and CRC32 trailer all check out.
+ """True if a WHOOP 5.0 frame clears the family minimum and its declared length, CRC16 header and
+ CRC32 trailer all check out.
The ack must only echo a genuine, intact HISTORY_END — CRC is the protocol's only integrity
check, so a garbled BLE frame must never advance the strap's trim cursor.
"""
- if len(frame) < 12 or frame[0] != 0xAA:
+ if len(frame) < WHOOP5_MIN_FRAME_BYTES or frame[0] != 0xAA:
return False
decl = frame[2] | (frame[3] << 8)
total = decl + 8
@@ -327,12 +339,13 @@ def build_history_ack(end_data: bytes, seq: int = 0) -> bytes:
def verify_whoop4_frame(frame: bytes) -> bool:
- """True if a WHOOP 4.0 frame's declared length, CRC8 header and CRC32 trailer all check out.
+ """True if a WHOOP 4.0 frame clears the family minimum and its declared length, CRC8 header and
+ CRC32 trailer all check out.
As on 5.0, the ack must only echo a genuine, intact HISTORY_END — CRC is the protocol's only
integrity check, so a garbled BLE frame must never advance the strap's trim cursor.
"""
- if len(frame) < 8 or frame[0] != 0xAA:
+ if len(frame) < WHOOP4_MIN_FRAME_BYTES or frame[0] != 0xAA:
return False
declared = frame[1] | (frame[2] << 8)
total = declared + 4
diff --git a/Tools/linux-capture/whoop_probe.py b/Tools/linux-capture/whoop_probe.py
index 5c359665de..ed926c17db 100644
--- a/Tools/linux-capture/whoop_probe.py
+++ b/Tools/linux-capture/whoop_probe.py
@@ -16,6 +16,7 @@
from bleak import BleakClient, BleakScanner
+from capture_io import configure_utf8_stdio
import whoop_frame as wf
WHOOP4 = {
@@ -147,4 +148,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/whoop_setclock.py b/Tools/linux-capture/whoop_setclock.py
index f8067015fa..d52eb659d5 100644
--- a/Tools/linux-capture/whoop_setclock.py
+++ b/Tools/linux-capture/whoop_setclock.py
@@ -25,6 +25,7 @@
from bleak import BleakClient, BleakScanner
+from capture_io import configure_utf8_stdio
import whoop_frame as wf
FAMILIES = {
@@ -167,4 +168,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/linux-capture/whoop_spot_hrv.py b/Tools/linux-capture/whoop_spot_hrv.py
index 8c9bd24b80..e58b3ca5bf 100644
--- a/Tools/linux-capture/whoop_spot_hrv.py
+++ b/Tools/linux-capture/whoop_spot_hrv.py
@@ -27,6 +27,7 @@
from collections import Counter
from statistics import median, pstdev
+from capture_io import configure_utf8_stdio
# ---------------------------------------------------------------- validated DSP (pure)
@@ -159,7 +160,13 @@ def main():
args = ap.parse_args()
# Read-only: open the DB in immutable/ro mode so this analysis tool can never write to whoop.db.
con = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
+ try:
+ return _run(args, con)
+ finally:
+ con.close()
+
+def _run(args, con):
windows = [(args.start, args.end)] if args.start and args.end else _covered_windows(con, args.device, args.channel)
if not windows:
print("no PPG-covered windows (need v26 optical bursts in feat_ppg for this device)")
@@ -179,4 +186,5 @@ def main():
if __name__ == "__main__":
+ configure_utf8_stdio()
raise SystemExit(main())
diff --git a/Tools/linux-capture/whoop_sync.py b/Tools/linux-capture/whoop_sync.py
index 0d163db5d5..eb33b25314 100644
--- a/Tools/linux-capture/whoop_sync.py
+++ b/Tools/linux-capture/whoop_sync.py
@@ -61,8 +61,9 @@
import sys
import time
-import whoop_frame as wf
import decode_features
+import whoop_frame as wf
+from capture_io import configure_utf8_stdio
# `bleak` is imported lazily inside the BLE functions (run/_acquire) so this module's frame helpers,
# Family, and WhoopDB can be imported and unit-tested with no third-party deps (like test_whoop_frame).
@@ -202,11 +203,24 @@ class WhoopDB:
def __init__(self, path):
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
self.db = sqlite3.connect(path, check_same_thread=False)
- self.db.executescript(SCHEMA)
- decode_features.apply_schema(self.db)
- self.db.execute("PRAGMA journal_mode=WAL")
- self.db.execute("PRAGMA synchronous=FULL")
- self.db.commit()
+ try:
+ self.db.executescript(SCHEMA)
+ decode_features.apply_schema(self.db)
+ self.db.execute("PRAGMA journal_mode=WAL")
+ self.db.execute("PRAGMA synchronous=FULL")
+ self.db.commit()
+ except BaseException:
+ self.db.close()
+ raise
+
+ def close(self):
+ self.db.close()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.close()
def upsert_device(self, address, name=None, subject=None, model=None):
address = address.upper()
@@ -280,7 +294,7 @@ def export_json(self, did, out_path, only_type=None, since=None):
recs = [{"hex": h, "char": c, "ts_ms": (rm or 0), "hr": hr} for h, c, rm, hr in rows]
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
tmp = out_path + ".tmp"
- with open(tmp, "w") as f:
+ with open(tmp, "w", encoding="utf-8") as f:
json.dump(recs, f, indent=1)
os.replace(tmp, out_path)
return len(recs)
@@ -681,14 +695,17 @@ async def watchdog():
return outcome["v"], s.committed - start
-async def run_realtime(args):
+async def run_realtime(args, db):
+ return await _run_realtime(args, db)
+
+
+async def _run_realtime(args, db):
"""Capture the live realtime stream (HR + R-R + IMU) into the durable store, reconnecting on drops
until --duration elapses, then decode → feat_rr so HRV/RSA can run. The realtime counterpart of the
offload `run()`; reuses the same _acquire + MTU-negotiated reconnect loop + WhoopDB."""
from bleak import BleakClient
from bleak.exc import BleakError
fam = Family(args.model)
- db = WhoopDB(args.db)
if not args.no_clock_check:
await preflight_clock(fam, args)
dev = await _acquire(args.address, tries=args.tries)
@@ -751,11 +768,14 @@ async def run_realtime(args):
print(f" decode skipped ({e}); run `whoop_sync.py decode --db {args.db}`.", flush=True)
-async def run(args):
+async def run(args, db):
+ return await _run(args, db)
+
+
+async def _run(args, db):
from bleak import BleakClient
from bleak.exc import BleakError
fam = Family(args.model)
- db = WhoopDB(args.db)
if not args.no_clock_check:
await preflight_clock(fam, args)
dev = await _acquire(args.address, tries=args.tries)
@@ -781,7 +801,7 @@ async def run(args):
s.emit = sys.stdout
sys.stdout = sys.stderr # subsequent print() diagnostics → stderr
elif emit_target:
- s.emit = open(emit_target, "w")
+ s.emit = open(emit_target, "w", encoding="utf-8")
if s.emit is not None:
s.emit.write(json.dumps({"_header": {"device_id": did, "address": info[0],
"name": info[1], "model": args.model}}) + "\n")
@@ -980,48 +1000,55 @@ def main():
if cmd == "sync":
try:
- asyncio.run(run(args))
+ with WhoopDB(args.db) as db:
+ asyncio.run(run(args, db))
except KeyboardInterrupt:
pass
elif cmd == "realtime":
try:
- asyncio.run(run_realtime(args))
+ with WhoopDB(args.db) as db:
+ asyncio.run(run_realtime(args, db))
except KeyboardInterrupt:
pass
elif cmd == "export":
- db = WhoopDB(args.db); did = _need_device(db, args.address)
- since = _parse_time(args.since) if args.since else None
- print(f"exported {db.export_json(did, args.out, args.only_type, since)} frames → {args.out}")
+ with WhoopDB(args.db) as db:
+ did = _need_device(db, args.address)
+ since = _parse_time(args.since) if args.since else None
+ print(f"exported {db.export_json(did, args.out, args.only_type, since)} frames → {args.out}")
elif cmd == "status":
- db = WhoopDB(args.db); did = _need_device(db, args.address)
- info = db.device_info(did); st = db.state(did); cov = db.coverage(did)
- print(f"db: {args.db}")
- print(f" device: {info[0]} name={info[1]!r} subject={info[2]!r} model={info[3]}")
- print(f" cursor last_trim={st.get('last_trim','?')} history_complete={st.get('history_complete','0')}")
- print(f" coverage: {_fmt(cov[0])} → {_fmt(cov[1])} ({cov[2] or 0} type-47)")
- print(f" counts by type: {db.counts(did)}")
- labs = db.labels(did)
- print(f" labels ({len(labs)}):")
- for s_, e_, a_, n_ in labs:
- print(f" {a_:12s} {_fmt(s_)} → {_fmt(e_)} {n_ or ''}")
+ with WhoopDB(args.db) as db:
+ did = _need_device(db, args.address)
+ info = db.device_info(did); st = db.state(did); cov = db.coverage(did)
+ print(f"db: {args.db}")
+ print(f" device: {info[0]} name={info[1]!r} subject={info[2]!r} model={info[3]}")
+ print(f" cursor last_trim={st.get('last_trim','?')} history_complete={st.get('history_complete','0')}")
+ print(f" coverage: {_fmt(cov[0])} → {_fmt(cov[1])} ({cov[2] or 0} type-47)")
+ print(f" counts by type: {db.counts(did)}")
+ labs = db.labels(did)
+ print(f" labels ({len(labs)}):")
+ for s_, e_, a_, n_ in labs:
+ print(f" {a_:12s} {_fmt(s_)} → {_fmt(e_)} {n_ or ''}")
elif cmd == "devices":
- db = WhoopDB(args.db)
- print(f"db: {args.db}")
- for did, addr, nm, subj, mdl, n47, mn, mx in db.list_devices():
- print(f" [{did}] {addr} name={nm!r} subject={subj!r} model={mdl} "
- f"{n47} type-47 {_fmt(mn)}→{_fmt(mx)}")
+ with WhoopDB(args.db) as db:
+ print(f"db: {args.db}")
+ for did, addr, nm, subj, mdl, n47, mn, mx in db.list_devices():
+ print(f" [{did}] {addr} name={nm!r} subject={subj!r} model={mdl} "
+ f"{n47} type-47 {_fmt(mn)}→{_fmt(mx)}")
elif cmd == "label":
- db = WhoopDB(args.db); did = _need_device(db, args.address)
- s_, e_ = _parse_time(args.start), _parse_time(args.end)
- db.add_label(did, s_, e_, args.activity, args.note)
- print(f"labeled {args.activity}: {_fmt(s_)} → {_fmt(e_)} for device {db.device_info(did)[0]}")
+ with WhoopDB(args.db) as db:
+ did = _need_device(db, args.address)
+ s_, e_ = _parse_time(args.start), _parse_time(args.end)
+ db.add_label(did, s_, e_, args.activity, args.note)
+ print(f"labeled {args.activity}: {_fmt(s_)} → {_fmt(e_)} for device {db.device_info(did)[0]}")
elif cmd == "decode":
- db = WhoopDB(args.db); did = _need_device(db, args.address)
- res = decode_features.decode_new(db, did, full=args.full)
- print(f"decode: {res['frames']} frames → feat_second updated "
- f"({res['decoded']} decoded, {res['skipped']} skipped); "
- f"cursor last_decoded_frame_id={res['cursor']}")
+ with WhoopDB(args.db) as db:
+ did = _need_device(db, args.address)
+ res = decode_features.decode_new(db, did, full=args.full)
+ print(f"decode: {res['frames']} frames → feat_second updated "
+ f"({res['decoded']} decoded, {res['skipped']} skipped); "
+ f"cursor last_decoded_frame_id={res['cursor']}")
if __name__ == "__main__":
+ configure_utf8_stdio()
main()
diff --git a/Tools/parity_cases/device_raw_sources.json b/Tools/parity_cases/device_raw_sources.json
new file mode 100644
index 0000000000..4a18baafbb
--- /dev/null
+++ b/Tools/parity_cases/device_raw_sources.json
@@ -0,0 +1,79 @@
+{
+ "sourceCases": [
+ {
+ "name": "active wins then paired and archived registered straps keep registry order before canonical",
+ "activeDeviceId": "whoop-new",
+ "registeredWhoopIds": ["whoop-archived", "my-whoop", "whoop-paired", "whoop-new"],
+ "imported": ["whoop-new", "whoop-archived", "whoop-paired", "my-whoop"],
+ "computed": ["whoop-new-noop", "whoop-archived-noop", "whoop-paired-noop", "my-whoop-noop"]
+ },
+ {
+ "name": "legacy source collapses without duplicate reads",
+ "activeDeviceId": "my-whoop",
+ "registeredWhoopIds": ["my-whoop"],
+ "imported": ["my-whoop"],
+ "computed": ["my-whoop-noop"]
+ },
+ {
+ "name": "explicit non-WHOOP active stays first before archived and paired WHOOP history",
+ "activeDeviceId": "polar-h10-live",
+ "registeredWhoopIds": ["whoop-archived", "my-whoop", "whoop-paired", "my-whoop"],
+ "imported": ["polar-h10-live", "whoop-archived", "whoop-paired", "my-whoop"],
+ "computed": ["polar-h10-live-noop", "whoop-archived-noop", "whoop-paired-noop", "my-whoop-noop"]
+ }
+ ],
+ "rrCases": [
+ {
+ "name": "exact identity tie keeps active while same timestamp distinct beats survive",
+ "sources": [
+ {
+ "id": "whoop-new",
+ "beats": [
+ {"ts": 102, "rrMs": 810, "seq": 0},
+ {"ts": 100, "rrMs": 800, "seq": 0, "ord": 10},
+ {"ts": 100, "rrMs": 810, "seq": 1}
+ ]
+ },
+ {
+ "id": "my-whoop",
+ "beats": [
+ {"ts": 99, "rrMs": 790, "seq": 0},
+ {"ts": 100, "rrMs": 800, "seq": 0, "ord": 20},
+ {"ts": 101, "rrMs": 805, "seq": 0}
+ ]
+ }
+ ],
+ "expected": [
+ {"source": "my-whoop", "ts": 99, "rrMs": 790, "seq": 0},
+ {"source": "whoop-new", "ts": 100, "rrMs": 800, "seq": 0, "ord": 10},
+ {"source": "whoop-new", "ts": 100, "rrMs": 810, "seq": 1},
+ {"source": "my-whoop", "ts": 101, "rrMs": 805, "seq": 0},
+ {"source": "whoop-new", "ts": 102, "rrMs": 810, "seq": 0}
+ ]
+ },
+ {
+ "name": "same timestamp orders by seq then rrMs before conflicting and nil ord",
+ "sources": [
+ {
+ "id": "whoop-new",
+ "beats": [
+ {"ts": 200, "rrMs": 700, "seq": 1},
+ {"ts": 200, "rrMs": 900, "seq": 0, "ord": 1},
+ {"ts": 200, "rrMs": 850, "seq": 0, "ord": 99},
+ {"ts": 200, "rrMs": 600, "seq": 2, "ord": 0}
+ ]
+ },
+ {
+ "id": "my-whoop",
+ "beats": []
+ }
+ ],
+ "expected": [
+ {"source": "whoop-new", "ts": 200, "rrMs": 850, "seq": 0, "ord": 99},
+ {"source": "whoop-new", "ts": 200, "rrMs": 900, "seq": 0, "ord": 1},
+ {"source": "whoop-new", "ts": 200, "rrMs": 700, "seq": 1},
+ {"source": "whoop-new", "ts": 200, "rrMs": 600, "seq": 2, "ord": 0}
+ ]
+ }
+ ]
+}
diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py
index ce2da56f11..cf25311dd1 100644
--- a/Tools/test_home_i18n.py
+++ b/Tools/test_home_i18n.py
@@ -96,6 +96,7 @@
"today.keyMetricsWindowDays", ",",
# Stable DashboardCard raw values + preference; units remain measurement metadata.
"stress", "fitnessAge", "vo2max", "vitality", "skinTemp", "sleep", "hydration", "coupled",
+ "coach",
"today.dashboardCards", "yrs", "kcal", "",
}
@@ -505,7 +506,9 @@ def test_apple_home_semantic_display_contracts(self) -> None:
self.assertNotIn('result.copy.contains("numbers agree")', (ROOT / "Strand/Screens/SkinTempCardsView.swift").read_text(encoding="utf-8"))
self.assertIn('String(localized: "RHR +\\(delta)")', app_model)
self.assertIn('String(localized: "HRV −\\(percent)%")', app_model)
- self.assertIn('String(localized: "Skin temperature +\\(temperature) °C")', app_model)
+ # 240c48ae (#1671): the label now carries the reader's unit via UnitFormatter.skinTempSignalPhrase,
+ # so the sign and the hardcoded °C moved out of the localized literal.
+ self.assertIn('String(localized: "Skin temperature \\(temperature)")', app_model)
self.assertIn('String(localized: "Respiration up")', app_model)
self.assertIn("public enum Evidence", readiness)
@@ -521,7 +524,7 @@ def test_apple_home_semantic_display_contracts(self) -> None:
strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"]
for key in (
- "RHR +%lld", "HRV −%lld%%", "Skin temperature +%@ °C", "Respiration up",
+ "RHR +%lld", "HRV −%lld%%", "Skin temperature %@", "Respiration up",
"%@ vs %@ %@", "7d %@ / 28d %@", "monotony %@",
):
self.assertIn(key, strings)
diff --git a/altstore-source.json b/altstore-source.json
index b581bd3a28..f16c7334cc 100644
--- a/altstore-source.json
+++ b/altstore-source.json
@@ -19,6 +19,24 @@
"category": "healthcare-fitness",
"screenshotURLs": [],
"versions": [
+ {
+ "version": "11.1.0",
+ "buildVersion": "299",
+ "date": "2026-09-02",
+ "localizedDescription": "NOOP 11.1.0. See the release notes for what changed.",
+ "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.1.0/NOOP-ios-unsigned-v11.1.0.ipa",
+ "size": 19387496,
+ "minOSVersion": "17.0"
+ },
+ {
+ "version": "11.0.0",
+ "buildVersion": "292",
+ "date": "2026-09-01",
+ "localizedDescription": "NOOP 11.0.0. See the release notes for what changed.",
+ "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.0.0/NOOP-ios-unsigned-v11.0.0.ipa",
+ "size": 19362325,
+ "minOSVersion": "17.0"
+ },
{
"version": "10.6.0",
"buildVersion": "241",
@@ -1393,12 +1411,12 @@
"NSMotionUsageDescription": "NOOP reads your iPhone's step count for a walking or running workout to show steps in the activity summary. It stays on your device. Nothing leaves it."
}
},
- "version": "10.6.0",
- "buildVersion": "241",
- "versionDate": "2026-08-25",
- "versionDescription": "NOOP 10.6.0. See the release notes for what changed.",
- "downloadURL": "https://github.com/ryanbr/noop/releases/download/v10.6.0/NOOP-ios-unsigned-v10.6.0.ipa",
- "size": 19019520,
+ "version": "11.1.0",
+ "buildVersion": "299",
+ "versionDate": "2026-09-02",
+ "versionDescription": "NOOP 11.1.0. See the release notes for what changed.",
+ "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.1.0/NOOP-ios-unsigned-v11.1.0.ipa",
+ "size": 19387496,
"minOSVersion": "17.0"
}
],
diff --git a/android/README.md b/android/README.md
index baee66a92b..c0a7e48337 100644
--- a/android/README.md
+++ b/android/README.md
@@ -3,7 +3,9 @@
An **offline WHOOP companion** for Android. NOOP connects directly to a WHOOP 4.0
(and WHOOP 5.0) strap over Bluetooth Low Energy, reads heart rate, R-R intervals,
battery, and sensor data, and stores everything **locally** on the device. There is
-no account, no server, and no `INTERNET` permission — nothing leaves the phone.
+no NOOP account, hosted server, or telemetry. Network access exists only for explicit,
+default-off features such as the AI Coach and Experimental one-way push to an endpoint
+the user owns.
This is an independent Kotlin / Jetpack Compose reimplementation of the macOS/iOS
reference app (`Strand/`, Swift). The protocol, framing, and BLE handshake are
@@ -189,7 +191,12 @@ install side-by-side.
## Permissions & why
-NOOP requests only what BLE needs, and deliberately omits `INTERNET`:
+NOOP requests the platform permissions its local features need. `INTERNET` supports only
+explicit, default-off network features (the AI Coach and Experimental self-hosted push):
+
+- **`INTERNET`** — lets those opt-in clients reach the endpoint the user selected. It is an
+ install-time capability, not evidence of background telemetry; neither feature runs until
+ configured.
- **`BLUETOOTH_SCAN`** (`neverForLocation`) + **`BLUETOOTH_CONNECT`** — Android 12+ (API 31+)
runtime permissions. `neverForLocation` lets us skip the location grant on modern Android
@@ -257,8 +264,8 @@ phone **and** a WHOOP strap.
**Privacy sanity check:**
-- [ ] Confirm the merged manifest contains **no `INTERNET` permission**
- (`app/build/intermediates/merged_manifests/…`) — the app must stay fully offline.
+- [ ] With AI Coach and self-hosted push unconfigured, confirm no HTTP request is made during
+ launch, BLE collection, offload, analytics, or normal navigation.
---
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 21857254e3..30bafcf48d 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -26,8 +26,8 @@ android {
applicationId = "com.noop.whoop"
minSdk = 26
targetSdk = 34
- versionCode = 373
- versionName = "10.6.1"
+ versionCode = 459
+ versionName = "11.1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -160,6 +160,16 @@ val roomSchemaDir = layout.buildDirectory.dir("generated/roomSchemas")
ksp {
arg("room.schemaLocation", roomSchemaDir.get().asFile.absolutePath)
}
+// Every KSP round for a Kotlin source set. Both users below start here and then diverge on the
+// UnitTest/AndroidTest rounds — in OPPOSITE directions, which is why this half is named rather than
+// spelled out twice:
+// - [isMainSourceSetKspTask] EXCLUDES them, so Gradle is not told they output a directory they never
+// write and then clears it out from under the round that did.
+// - `syncRoomSchemaSnapshot`'s `mustRunAfter` INCLUDES them, because they still race the Sync (#1711).
+// Two near-identical predicates meaning opposite things is a trap; sharing the common half makes the
+// difference the thing the reader sees.
+fun isKspKotlinTask(name: String) = name.startsWith("ksp") && name.endsWith("Kotlin")
+
// Room writes the export as a SIDE EFFECT of annotation processing, at a path Gradle knows nothing
// about. Left undeclared, a KSP task that is UP-TO-DATE or restored FROM-CACHE leaves whatever the last
// real execution wrote — so an entity edit that is later reverted keeps the stale export on disk and the
@@ -178,8 +188,7 @@ ksp {
// wiped the export that `kspFullDebugKotlin` had just restored, and the test failed on a clean build with
// a warm cache with the directory missing entirely.
fun isMainSourceSetKspTask(name: String) =
- name.startsWith("ksp") && name.endsWith("Kotlin") &&
- !name.contains("UnitTest") && !name.contains("AndroidTest")
+ isKspKotlinTask(name) && !name.contains("UnitTest") && !name.contains("AndroidTest")
tasks.matching { isMainSourceSetKspTask(it.name) }.configureEach {
outputs.dir(roomSchemaDir).withPropertyName("roomSchemaExport")
@@ -208,8 +217,17 @@ val syncRoomSchemaSnapshot = tasks.register("syncRoomSchemaSnapshot") {
// variant's KSP round writes byte-identical JSON. Depending on all of them would make a single
// `testFullDebugUnitTest` run four KSP rounds instead of one.
dependsOn(tasks.matching { it.name == "kspFullDebugKotlin" })
- // Unit-test KSP can clear the snapshot after Sync has copied it; order Sync after those tasks.
- mustRunAfter(tasks.matching { it.name.startsWith("ksp") && it.name.contains("UnitTest") })
+ // Every KSP round writes `roomSchemaDir` — `room.schemaLocation` is set once, outside any variant
+ // block — so EVERY one of them is both a producer Sync must not race and a producer Gradle expects
+ // a declared relationship with. This was scoped to the UnitTest rounds, which covered the observed
+ // hazard (unit-test KSP clearing the snapshot after Sync copied it) but left `kspFullReleaseKotlin`
+ // out. It is normally absent from the graph; pull it in — `lintVitalFullRelease` in the same
+ // invocation as the debug test tasks does — and Gradle fails the build for an undeclared
+ // producer/consumer pair, with an error that reads like a Room problem (#1711).
+ //
+ // Ordering only, so nothing is forced to execute and the one-variant `dependsOn` above keeps its
+ // point: a `testFullDebugUnitTest` run still triggers exactly one KSP round.
+ mustRunAfter(tasks.matching { isKspKotlinTask(it.name) })
}
tasks.withType().configureEach {
@@ -280,6 +298,7 @@ dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("org.json:json:20240303") // real org.json for JVM unit tests (android.jar ships throwing stubs)
testImplementation("net.sf.kxml:kxml2:2.3.0") // real XmlPullParser for JVM tests (android.util.Xml is a throwing stub)
+ testImplementation("org.xerial:sqlite-jdbc:3.41.2.2") // execute DAO aggregate SQL in plain-JVM tests
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile
index ef15d72d51..b60ae7c042 100644
--- a/android/app/gradle.lockfile
+++ b/android/app/gradle.lockfile
@@ -283,5 +283,5 @@ org.json:json:20240303=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntim
org.ow2.asm:asm-commons:9.7=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata
org.ow2.asm:asm-tree:9.7=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata
org.ow2.asm:asm:9.7=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata
-org.xerial:sqlite-jdbc:3.41.2.2=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath
+org.xerial:sqlite-jdbc:3.41.2.2=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath
empty=_agp_internal_javaPreCompileDemoDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileDemoDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileDemoReleaseUnitTest_kspClasspath,_agp_internal_javaPreCompileFullDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileFullDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileFullReleaseUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestApiDependenciesMetadata,androidTestCompileOnlyDependenciesMetadata,androidTestDebugApiDependenciesMetadata,androidTestDebugCompileOnlyDependenciesMetadata,androidTestDebugImplementationDependenciesMetadata,androidTestDebugIntransitiveDependenciesMetadata,androidTestDemoApiDependenciesMetadata,androidTestDemoCompileOnlyDependenciesMetadata,androidTestDemoDebugApiDependenciesMetadata,androidTestDemoDebugCompileOnlyDependenciesMetadata,androidTestDemoDebugImplementationDependenciesMetadata,androidTestDemoDebugIntransitiveDependenciesMetadata,androidTestDemoImplementationDependenciesMetadata,androidTestDemoIntransitiveDependenciesMetadata,androidTestFullApiDependenciesMetadata,androidTestFullCompileOnlyDependenciesMetadata,androidTestFullDebugApiDependenciesMetadata,androidTestFullDebugCompileOnlyDependenciesMetadata,androidTestFullDebugImplementationDependenciesMetadata,androidTestFullDebugIntransitiveDependenciesMetadata,androidTestFullImplementationDependenciesMetadata,androidTestFullIntransitiveDependenciesMetadata,androidTestIntransitiveDependenciesMetadata,androidTestReleaseApiDependenciesMetadata,androidTestReleaseCompileOnlyDependenciesMetadata,androidTestReleaseImplementationDependenciesMetadata,androidTestReleaseIntransitiveDependenciesMetadata,androidTestUtil,compileOnlyDependenciesMetadata,coreLibraryDesugaring,debugApiDependenciesMetadata,debugCompileOnlyDependenciesMetadata,debugIntransitiveDependenciesMetadata,demoApiDependenciesMetadata,demoCompileOnlyDependenciesMetadata,demoDebugAndroidTestAnnotationProcessorClasspath,demoDebugAndroidTestApiDependenciesMetadata,demoDebugAndroidTestCompileOnlyDependenciesMetadata,demoDebugAndroidTestImplementationDependenciesMetadata,demoDebugAndroidTestIntransitiveDependenciesMetadata,demoDebugAnnotationProcessorClasspath,demoDebugApiDependenciesMetadata,demoDebugCompileOnlyDependenciesMetadata,demoDebugImplementationDependenciesMetadata,demoDebugIntransitiveDependenciesMetadata,demoDebugReverseMetadataValues,demoDebugUnitTestAnnotationProcessorClasspath,demoDebugUnitTestApiDependenciesMetadata,demoDebugUnitTestCompileOnlyDependenciesMetadata,demoDebugUnitTestImplementationDependenciesMetadata,demoDebugUnitTestIntransitiveDependenciesMetadata,demoDebugWearBundling,demoImplementationDependenciesMetadata,demoIntransitiveDependenciesMetadata,demoReleaseAnnotationProcessorClasspath,demoReleaseApiDependenciesMetadata,demoReleaseCompileOnlyDependenciesMetadata,demoReleaseImplementationDependenciesMetadata,demoReleaseIntransitiveDependenciesMetadata,demoReleaseReverseMetadataValues,demoReleaseUnitTestAnnotationProcessorClasspath,demoReleaseUnitTestApiDependenciesMetadata,demoReleaseUnitTestCompileOnlyDependenciesMetadata,demoReleaseUnitTestImplementationDependenciesMetadata,demoReleaseUnitTestIntransitiveDependenciesMetadata,demoReleaseWearBundling,fullApiDependenciesMetadata,fullCompileOnlyDependenciesMetadata,fullDebugAndroidTestAnnotationProcessorClasspath,fullDebugAndroidTestApiDependenciesMetadata,fullDebugAndroidTestCompileOnlyDependenciesMetadata,fullDebugAndroidTestImplementationDependenciesMetadata,fullDebugAndroidTestIntransitiveDependenciesMetadata,fullDebugAnnotationProcessorClasspath,fullDebugApiDependenciesMetadata,fullDebugCompileOnlyDependenciesMetadata,fullDebugImplementationDependenciesMetadata,fullDebugIntransitiveDependenciesMetadata,fullDebugReverseMetadataValues,fullDebugUnitTestAnnotationProcessorClasspath,fullDebugUnitTestApiDependenciesMetadata,fullDebugUnitTestCompileOnlyDependenciesMetadata,fullDebugUnitTestImplementationDependenciesMetadata,fullDebugUnitTestIntransitiveDependenciesMetadata,fullDebugWearBundling,fullImplementationDependenciesMetadata,fullIntransitiveDependenciesMetadata,fullReleaseAnnotationProcessorClasspath,fullReleaseApiDependenciesMetadata,fullReleaseCompileOnlyDependenciesMetadata,fullReleaseImplementationDependenciesMetadata,fullReleaseIntransitiveDependenciesMetadata,fullReleaseReverseMetadataValues,fullReleaseUnitTestAnnotationProcessorClasspath,fullReleaseUnitTestApiDependenciesMetadata,fullReleaseUnitTestCompileOnlyDependenciesMetadata,fullReleaseUnitTestImplementationDependenciesMetadata,fullReleaseUnitTestIntransitiveDependenciesMetadata,fullReleaseWearBundling,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,releaseApiDependenciesMetadata,releaseCompileOnlyDependenciesMetadata,releaseImplementationDependenciesMetadata,releaseIntransitiveDependenciesMetadata,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testDebugApiDependenciesMetadata,testDebugCompileOnlyDependenciesMetadata,testDebugImplementationDependenciesMetadata,testDebugIntransitiveDependenciesMetadata,testDemoApiDependenciesMetadata,testDemoCompileOnlyDependenciesMetadata,testDemoDebugApiDependenciesMetadata,testDemoDebugCompileOnlyDependenciesMetadata,testDemoDebugImplementationDependenciesMetadata,testDemoDebugIntransitiveDependenciesMetadata,testDemoImplementationDependenciesMetadata,testDemoIntransitiveDependenciesMetadata,testDemoReleaseApiDependenciesMetadata,testDemoReleaseCompileOnlyDependenciesMetadata,testDemoReleaseImplementationDependenciesMetadata,testDemoReleaseIntransitiveDependenciesMetadata,testFixturesApiDependenciesMetadata,testFixturesCompileOnlyDependenciesMetadata,testFixturesDebugApiDependenciesMetadata,testFixturesDebugCompileOnlyDependenciesMetadata,testFixturesDebugImplementationDependenciesMetadata,testFixturesDebugIntransitiveDependenciesMetadata,testFixturesDemoApiDependenciesMetadata,testFixturesDemoCompileOnlyDependenciesMetadata,testFixturesDemoImplementationDependenciesMetadata,testFixturesDemoIntransitiveDependenciesMetadata,testFixturesFullApiDependenciesMetadata,testFixturesFullCompileOnlyDependenciesMetadata,testFixturesFullImplementationDependenciesMetadata,testFixturesFullIntransitiveDependenciesMetadata,testFixturesImplementationDependenciesMetadata,testFixturesIntransitiveDependenciesMetadata,testFixturesReleaseApiDependenciesMetadata,testFixturesReleaseCompileOnlyDependenciesMetadata,testFixturesReleaseImplementationDependenciesMetadata,testFixturesReleaseIntransitiveDependenciesMetadata,testFullApiDependenciesMetadata,testFullCompileOnlyDependenciesMetadata,testFullDebugApiDependenciesMetadata,testFullDebugCompileOnlyDependenciesMetadata,testFullDebugImplementationDependenciesMetadata,testFullDebugIntransitiveDependenciesMetadata,testFullImplementationDependenciesMetadata,testFullIntransitiveDependenciesMetadata,testFullReleaseApiDependenciesMetadata,testFullReleaseCompileOnlyDependenciesMetadata,testFullReleaseImplementationDependenciesMetadata,testFullReleaseIntransitiveDependenciesMetadata,testIntransitiveDependenciesMetadata,testReleaseApiDependenciesMetadata,testReleaseCompileOnlyDependenciesMetadata,testReleaseImplementationDependenciesMetadata,testReleaseIntransitiveDependenciesMetadata
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6a1734b023..3ce804c414 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -3,10 +3,10 @@
xmlns:tools="http://schemas.android.com/tools">
@@ -61,6 +61,14 @@
request READ_CALL_LOG and never reads incoming numbers, contacts, or call history. -->
+
+
+
+
+
+
@@ -277,6 +291,34 @@
android:resource="@xml/noop_compact_widget_info" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/noop/CrashCapture.kt b/android/app/src/main/java/com/noop/CrashCapture.kt
index 5c6b2231df..6924b11967 100644
--- a/android/app/src/main/java/com/noop/CrashCapture.kt
+++ b/android/app/src/main/java/com/noop/CrashCapture.kt
@@ -1,9 +1,11 @@
package com.noop
import android.content.Context
+import android.os.Build
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
+import java.security.MessageDigest
/**
* Captures the last uncaught exception to a file so a crash that only reproduces on a user's own
@@ -14,6 +16,8 @@ import java.io.StringWriter
*/
object CrashCapture {
private const val FILE = "last_crash.txt"
+ private const val PREFS = "noop_crash_capture"
+ private const val ACKNOWLEDGED = "acknowledged_fingerprint"
fun install(context: Context) {
val appContext = context.applicationContext
@@ -24,8 +28,17 @@ object CrashCapture {
val sw = StringWriter()
throwable.printStackTrace(PrintWriter(sw))
val text = buildString {
- appendLine("when: ${java.util.Date()}")
- appendLine("thread: ${thread.name}")
+ append(crashHeader(
+ whenText = java.util.Date().toString(),
+ threadName = thread.name,
+ appVersion = BuildConfig.VERSION_NAME,
+ versionCode = BuildConfig.VERSION_CODE,
+ packageName = appContext.packageName,
+ androidRelease = Build.VERSION.RELEASE,
+ sdk = Build.VERSION.SDK_INT,
+ manufacturer = Build.MANUFACTURER,
+ model = Build.MODEL,
+ ))
appendLine(sw.toString())
}
File(appContext.filesDir, FILE).writeText(text)
@@ -40,4 +53,84 @@ object CrashCapture {
if (!f.exists()) return null
return runCatching { f.readText() }.getOrNull()?.ifBlank { null }
}
+
+ /**
+ * A crash not yet dismissed by the user, shown before launch touches the database or BLE stack.
+ *
+ * TOTAL, like [lastCrash] beside it: this is the FIRST thing `MainActivity.onCreate` runs, so a
+ * throw here does not degrade the recovery screen — it stops the app starting at all, on every
+ * launch, with no screen left to explain why. A crash-recovery feature that bricks startup is a
+ * worse failure than the crash it exists to report, so any failure reading the acknowledgement
+ * yields null and normal startup proceeds.
+ */
+ fun pendingCrash(context: Context): String? = runCatching {
+ val app = context.applicationContext
+ val crash = lastCrash(context) ?: return@runCatching null
+ // A crash from a build the user has already replaced is not news. The capture handler has written
+ // this file since June while the screen that surfaces it landed in August, so on the first launch
+ // after upgrading, every install carrying an old crash would open on "NOOP stopped unexpectedly"
+ // for something survived weeks earlier — which is how this was found: a 53-day-old crash greeted a
+ // staging build that had not crashed at all.
+ val lastUpdate = runCatching {
+ app.packageManager.getPackageInfo(app.packageName, 0).lastUpdateTime
+ }.getOrDefault(0L)
+ if (isPreUpgradeCrash(File(app.filesDir, FILE).lastModified(), lastUpdate)) return@runCatching null
+ val acknowledged = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getString(ACKNOWLEDGED, null)
+ crash.takeIf { isPending(fingerprint(it), acknowledged) }
+ }.getOrNull()
+
+ /**
+ * Keep the crash for diagnostics, but allow the next launch attempt to continue.
+ *
+ * `commit()` rather than `apply()`: the caller recreates the Activity immediately, and a process
+ * death between the two would lose an asynchronous write and put the same crash back in front of
+ * the user. The write is one small string on a path taken once per crash, so paying for durability
+ * here costs nothing measurable and removes the only way this can repeat itself.
+ */
+ fun acknowledge(context: Context, crash: String) {
+ runCatching {
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit().putString(ACKNOWLEDGED, fingerprint(crash)).commit()
+ }
+ }
+
+ /**
+ * Whether a stored crash belongs to a build the user is no longer running.
+ *
+ * `lastUpdateTime` is when this APK was installed or updated, so a crash file older than it was
+ * written by a version that has since been replaced. The file is NOT deleted — the strap log and the
+ * test bundle still carry it, which is exactly how the long-standing R8 minification crash trace was
+ * recovered — it simply stops opening the app on a recovery screen.
+ *
+ * Fails toward SHOWING. If either timestamp is unknown (0), this returns false and the crash surfaces
+ * as before: the screen exists to report crashes, so an occasional stale one is a smaller failure than
+ * silently swallowing a real one. The acknowledgement fingerprint still caps that at a single screen.
+ */
+ internal fun isPreUpgradeCrash(crashModifiedMs: Long, lastUpdateMs: Long): Boolean =
+ crashModifiedMs > 0L && lastUpdateMs > 0L && crashModifiedMs < lastUpdateMs
+
+ internal fun isPending(fingerprint: String, acknowledged: String?) = fingerprint != acknowledged
+
+ internal fun fingerprint(text: String): String = MessageDigest.getInstance("SHA-256")
+ .digest(text.toByteArray(Charsets.UTF_8))
+ .joinToString("") { "%02x".format(it) }
+
+ internal fun crashHeader(
+ whenText: String,
+ threadName: String,
+ appVersion: String,
+ versionCode: Int,
+ packageName: String,
+ androidRelease: String,
+ sdk: Int,
+ manufacturer: String,
+ model: String,
+ ) = buildString {
+ appendLine("when: $whenText")
+ appendLine("app: $appVersion ($versionCode) · $packageName")
+ appendLine("os: Android $androidRelease (API $sdk)")
+ appendLine("device: $manufacturer $model")
+ appendLine("thread: $threadName")
+ }
}
diff --git a/android/app/src/main/java/com/noop/NoopApplication.kt b/android/app/src/main/java/com/noop/NoopApplication.kt
index e253344f28..3345cb9634 100644
--- a/android/app/src/main/java/com/noop/NoopApplication.kt
+++ b/android/app/src/main/java/com/noop/NoopApplication.kt
@@ -6,6 +6,7 @@ import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import android.util.Log
import com.noop.ble.SourceCoordinator
+import com.noop.ble.SourceIdentity
import com.noop.ble.WhoopBleClient
import com.noop.ble.WhoopModel
import com.noop.data.DeviceRegistry
@@ -13,13 +14,15 @@ import com.noop.data.WhoopDatabase
import com.noop.data.WhoopRepository
import com.noop.ui.NoopPrefs
import com.noop.ui.AppLanguagePrefs
+import com.noop.push.SelfHostedPushScheduler
import kotlinx.coroutines.runBlocking
/**
* Application entry point.
*
* NOOP is a fully on-device WHOOP companion: it connects to the strap over BLE and persists
- * everything locally via Room. There is no network layer (the opt-in AI Coach aside).
+ * everything locally via Room. Network access is opt-in: the AI Coach and the experimental,
+ * one-way self-hosted push are both disabled by default.
*
* The data layer ([WhoopRepository]) and the BLE client ([WhoopBleClient]) are owned **here**, at the
* process level, rather than by the Activity-scoped AppViewModel. That is what lets a connection keep
@@ -41,12 +44,12 @@ class NoopApplication : Application() {
override fun onCreate() {
super.onCreate()
+ // Install before any app-owned startup work so even an early failure is preserved for the
+ // recovery screen on the next launch.
+ CrashCapture.install(this)
// #1008: pin the pre-change Overnight-only default for existing installs before anything
// reads it. Idempotent; a no-op on fresh installs and on every launch after the first.
com.noop.ui.NoopPrefs.migrateContinuousHrvOvernightDefault(this)
- // Record any uncaught crash to a file so it rides along in the shareable strap log — a
- // device-specific crash (e.g. Insights #224/#267) is otherwise lost to an unreachable logcat.
- CrashCapture.install(this)
}
/** Process-wide Room-backed store. One instance shared by the UI and the background service. */
@@ -58,20 +61,74 @@ class NoopApplication : Application() {
val deviceRegistry: DeviceRegistry by lazy { DeviceRegistry(WhoopDatabase.get(this)) }
/**
- * Active device id resolved once at startup from the registry, falling back to the legacy
- * "my-whoop" if the registry has none yet (so behaviour is unchanged today). Read with a guarded
- * blocking call — a one-off indexed `LIMIT 1` query at composition time. Any failure (e.g. an early
- * read before migration) is swallowed and falls back, so startup can never be broken by this.
+ * Active device id, resolved once at startup from the registry and falling back to the legacy
+ * "my-whoop" if the registry has none yet. Read with a guarded blocking call — a one-off indexed
+ * `LIMIT 1` query at composition time. Any failure (e.g. an early read before migration) is swallowed
+ * and falls back, so startup can never be broken by this.
+ *
+ * #1303: NOT a `by lazy`. Serial adoption re-points the ACTIVE device mid-process, and a lazy is
+ * frozen for the life of the process — so every consumer below kept the pre-adoption id until the next
+ * cold start, and the engine went on deriving days under it. Field-confirmed on a 5/MG: the registry
+ * read `whoop-` while the diagnostics export, and the scoring pass, still used the old
+ * address-based id, splitting the computed history across both until the phone was restarted. Adoption
+ * now calls [onActiveDeviceAdopted] and the handle follows within the process.
+ */
+ @Volatile
+ var activeDeviceId: String = ""
+ get() {
+ if (field.isEmpty()) {
+ field = runCatching { runBlocking { deviceRegistry.activeDeviceId() } }
+ .onFailure { Log.w("NoopApplication", "activeDeviceId resolve failed; using fallback", it) }
+ .getOrNull() ?: WhoopBleClient.DEFAULT_DEVICE_ID
+ }
+ return field
+ }
+ private set
+
+ /**
+ * Point this process at the id a strap just adopted (#1303).
+ *
+ * Only the handle moves: the registry write and the row migration have already happened inside
+ * `adoptSerialIdentity`, and the BLE client is re-pointed by its own caller. Kept narrow and
+ * idempotent so a reconnect that re-adopts the same id costs nothing.
*/
- val activeDeviceId: String by lazy {
- runCatching { runBlocking { deviceRegistry.activeDeviceId() } }
- .onFailure { Log.w("NoopApplication", "activeDeviceId resolve failed; using fallback", it) }
- .getOrNull() ?: WhoopBleClient.DEFAULT_DEVICE_ID
+ fun onActiveDeviceAdopted(newId: String) {
+ if (newId.isNotEmpty() && newId != activeDeviceId) activeDeviceId = newId
+ }
+
+ /**
+ * The id the BLE client should stamp WHOOP samples with at startup (#1881).
+ *
+ * [activeDeviceId] answers "which device did the user select", which is NOT the same question once a
+ * non-WHOOP device can be active: handing it to the client made every WHOOP live sample and historical
+ * chunk persist under, say, an Oura ring. `adoptSourceIdentity` corrects the id when a strap actually
+ * connects, but not for the window between construction and that connect, so the wrong value must not
+ * be adopted in the first place.
+ *
+ * Fail-open: an unreadable registry or an unclassifiable row keeps today's behaviour. Only a
+ * POSITIVELY non-WHOOP active device falls back to the legacy id. Swift twin: `BLEManager.bootstrapStore`.
+ */
+ private fun whoopStartupDeviceId(): String {
+ val id = activeDeviceId
+ val rows = runCatching { runBlocking { deviceRegistry.all() } }.getOrNull() ?: return id
+ val row = rows.firstOrNull { it.id == id } ?: return id
+ return if (SourceIdentity.isWhoop(row)) id else WhoopBleClient.DEFAULT_DEVICE_ID
}
/** Process-wide BLE client. Owns the GATT connection and outlives any single Activity/ViewModel. */
val ble: WhoopBleClient by lazy {
- WhoopBleClient(applicationContext, repository = repository, deviceId = activeDeviceId).apply {
+ val startupId = whoopStartupDeviceId()
+ WhoopBleClient(
+ applicationContext,
+ repository = repository,
+ deviceId = startupId,
+ successfulOffloadSink = {
+ SelfHostedPushScheduler.enqueueAfterSuccessfulOffload(applicationContext)
+ },
+ ).apply {
+ // #1881: the same fact seeds the connect gate, closing the launch race where the radio can
+ // reach the WHOOP flow before SourceCoordinator has wired up and asserted it.
+ if (startupId != activeDeviceId) setWhoopIsActiveDevice(false)
// Apply the persisted "Debug logging" preference at the composition root so the low-level
// client never has to read the UI/prefs layer. Default OFF — see WhoopBleClient.debugLogcat.
debugLogcat = NoopPrefs.debugLogging(applicationContext)
@@ -106,8 +163,12 @@ class NoopApplication : Application() {
// path (status=133 on an OS-bonded strap). Mirrors macOS AppModel.scan() reading the persisted
// "selectedWhoopModel". Same-strap switches now adopt in place (no reconnect) via the
// coordinator, so this only fires for a genuinely different WHOOP.
- startWhoop = { ble.connect(persistedWhoopModel()) },
- stopWhoop = { ble.disconnect() },
+ // #1881: the flag rides the SAME two closures, so it inherits the coordinator's semantics
+ // exactly. `stopWhoop` alone was edge-triggered: it dropped the link once and nothing stopped
+ // `onBluetoothRadioOn` bringing it straight back — every Bluetooth toggle reached it, and it
+ // clears `intentionalDisconnect` before reconnecting. Swift twin: AppModel.wireSourceCoordinator.
+ startWhoop = { ble.setWhoopIsActiveDevice(true); ble.connect(persistedWhoopModel()) },
+ stopWhoop = { ble.setWhoopIsActiveDevice(false); ble.disconnect() },
// Multi-WHOOP (MW-2/MW-3): pin the connection to the active WHOOP's persisted address and
// re-attribute live samples to it on a WHOOP→WHOOP switch. Both inert on the single-WHOOP
// path — the coordinator only invokes them for a non-legacy WHOOP / a non-null peripheralId.
diff --git a/android/app/src/main/java/com/noop/ai/AiCoach.kt b/android/app/src/main/java/com/noop/ai/AiCoach.kt
index 68c1a4d5c5..beb19e1bfd 100644
--- a/android/app/src/main/java/com/noop/ai/AiCoach.kt
+++ b/android/app/src/main/java/com/noop/ai/AiCoach.kt
@@ -1,9 +1,12 @@
package com.noop.ai
import android.content.Context
+import com.noop.analytics.CoachSuggestions
import com.noop.analytics.EffectRanker
import com.noop.analytics.LabMarkerCategory
import com.noop.analytics.MarkerCatalog
+import com.noop.analytics.SseDeltas
+import com.noop.analytics.SseProvider
import com.noop.analytics.StressIndex
import com.noop.data.DailyMetric
import com.noop.data.JournalEntry
@@ -24,10 +27,10 @@ import kotlin.math.roundToInt
/**
* The AI Coach.
*
- * Privacy posture: this is the ONE networked feature in the app. Nothing leaves the device
- * until the user has saved their own API key (see [AiKeyStore]) and asked a question. Only a
- * compact plain-text summary of their metrics plus their question is sent to the provider the
- * user picked. No raw samples, no identifiers.
+ * Privacy posture: this is an opt-in networked feature, independent of the default-off Experimental
+ * self-hosted push. Nothing leaves through the Coach until the user has saved their own API key (see
+ * [AiKeyStore]) and asked a question. Only a compact plain-text summary of their metrics plus their
+ * question is sent to the provider the user picked. No raw samples, no identifiers.
*
* Anonymous: the only branding is the provider name the user selected. The system prompt does
* not name any app author or model vendor.
@@ -50,6 +53,10 @@ class AiCoach(
* [activeStrapId]. */
private val deviceId = "my-whoop"
+ // K13: cached summary of the dropped middle turns, regenerated when the dropped set changes.
+ @Volatile private var droppedSummary: String? = null
+ @Volatile private var droppedSummaryKey: List = emptyList()
+
/** The source id native (in-app) journal answers are stored under (matches the UI's
* JOURNAL_DEVICE_ID); used for the opt-in on-device-signals context only. */
private val journalDeviceId = "noop-journal"
@@ -127,39 +134,106 @@ class AiCoach(
// Slide a window over a long conversation so the history can't crowd out the reply on a
// small local context window (e.g. Ollama's 2048-token default). The first user turn carries
// the data context, so it is always kept; the middle is dropped, the recent tail retained.
+ // K13: summarize the dropped middle so the model retains context continuity (best-effort).
val grounded = trimmedHistory(groundedFull, MAX_HISTORY_TURNS)
+ val groundedWithSummary = injectDroppedSummary(grounded, groundedFull)
when (provider) {
AiProvider.OPENAI ->
- callOpenAiCompatible(provider, provider.endpoint, model, key, grounded, systemPrompt)
+ callOpenAiCompatible(provider, provider.endpoint, model, key, groundedWithSummary, systemPrompt)
AiProvider.ANTHROPIC ->
- callAnthropic(provider, model, key!!, grounded, systemPrompt)
+ callAnthropic(provider, model, key!!, groundedWithSummary, systemPrompt)
AiProvider.GEMINI ->
- callGemini(provider, model, key!!, grounded, systemPrompt)
+ callGemini(provider, model, key!!, groundedWithSummary, systemPrompt)
AiProvider.CUSTOM ->
callOpenAiCompatible(
provider,
customChatUrl(customBaseUrl),
model,
key,
- grounded,
+ groundedWithSummary,
systemPrompt,
customAuthHeader,
)
}
}
+ /**
+ * K1: Stream the conversation to [provider] using [model], calling [onDelta] for each text
+ * chunk as it arrives. Same context injection, consent gating, and error mapping as [chat];
+ * streaming changes transport, not payload. The concatenated deltas are byte-identical to
+ * what [chat] would return (parity pin in `SseDeltasTest`). Runs on [Dispatchers.IO].
+ *
+ * On error mid-stream, throws — the caller keeps the partial text and appends an interrupted
+ * marker. Never crashes; the ViewModel maps exceptions to a visible error.
+ */
+ suspend fun chatStream(
+ ctx: Context,
+ history: List,
+ provider: AiProvider,
+ model: String,
+ consent: Boolean = false,
+ customBaseUrl: String = "",
+ customAuthHeader: CustomAiAuthHeader = CustomAiAuthHeader.BEARER,
+ includeSignals: Boolean = false,
+ onDelta: (String) -> Unit,
+ ): Unit = withContext(Dispatchers.IO) {
+ val key = AiKeyStore.read(ctx, provider)
+ if (key == null && provider != AiProvider.CUSTOM) {
+ throw Exception("No API key set. Add your ${provider.displayName} key to use the coach.")
+ }
+ if (provider == AiProvider.CUSTOM) {
+ require(customBaseUrl.isNotBlank()) { "Set your server URL first." }
+ require(model.isNotBlank()) { "Pick a model your server serves." }
+ }
+
+ require(history.isNotEmpty()) { "Ask a question first." }
+ require(history.last().role == "user") { "The last message must be your question." }
+
+ val groundedFull = if (consent) {
+ val days = runCatching { repo.daysMerged(activeStrapId()) }.getOrDefault(emptyList())
+ val stress = runCatching { stressLineToday() }.getOrNull()
+ val signals = if (includeSignals) runCatching { buildSignalsContext() }.getOrNull() else null
+ val full = buildString {
+ append(buildContext(days))
+ if (!stress.isNullOrBlank()) append("\n\n").append(stress)
+ if (!signals.isNullOrBlank()) append("\n\n").append(signals)
+ }
+ injectContext(history, full)
+ } else {
+ injectContext(history, NO_CONSENT_NOTE)
+ }
+
+ val systemPrompt = resolveSystemPrompt(ctx)
+ val grounded = trimmedHistory(groundedFull, MAX_HISTORY_TURNS)
+ val groundedWithSummary = injectDroppedSummary(grounded, groundedFull)
+
+ when (provider) {
+ AiProvider.OPENAI ->
+ callOpenAiCompatibleStream(provider, provider.endpoint, model, key, groundedWithSummary, systemPrompt, onDelta)
+ AiProvider.ANTHROPIC ->
+ callAnthropicStream(provider, model, key!!, groundedWithSummary, systemPrompt, onDelta)
+ AiProvider.GEMINI ->
+ callGeminiStream(provider, model, key!!, groundedWithSummary, systemPrompt, onDelta)
+ AiProvider.CUSTOM ->
+ callOpenAiCompatibleStream(
+ provider, customChatUrl(customBaseUrl), model, key, groundedWithSummary, systemPrompt, onDelta, customAuthHeader,
+ )
+ }
+ }
+
/**
* Today's derived stress line for the consent-gated coach context. Reads R-R for the local day
- * via [WhoopRepository.rrIntervals] (the SAME path StressScreen uses) and summarises it with the
- * pure [stressIndexLine]. Returns null when there aren't enough clean beats. Summary number only, * the raw R-R never leaves the device.
+ * via [WhoopRepository.rrIntervalsUnion] (the SAME path StressScreen uses) and summarises it with the
+ * pure [stressIndexLine]. Returns null when there aren't enough clean beats. Summary number only;
+ * the raw R-R never leaves the device.
*/
private suspend fun stressLineToday(): String? {
val nowSeconds = System.currentTimeMillis() / 1000L
val tzOffset = java.util.TimeZone.getDefault().getOffset(nowSeconds * 1_000L) / 1_000L
val localNow = nowSeconds + tzOffset
val from = (localNow - Math.floorMod(localNow, 86_400L)) - tzOffset
- val rr = repo.rrIntervals(activeStrapId(), from, nowSeconds, limit = 200_000)
+ val rr = repo.rrIntervalsUnion(activeStrapId(), from, nowSeconds, limit = 200_000)
return stressIndexLine(rr)
}
@@ -220,6 +294,85 @@ class AiCoach(
}.getOrDefault(emptyList())
}
+ // ---------------------------------------------------------------------------------------
+ // K5: scheduled morning-brief generation (headless, no chat UI involved)
+ // ---------------------------------------------------------------------------------------
+
+ /**
+ * K5: generate today's coaching brief with NO chat transcript involved — used by the scheduled
+ * morning-brief notification ([com.noop.notif.CoachBriefWorker]), which runs headless (no
+ * Activity/ViewModel) and must not touch [CoachViewModel]'s in-memory messages. Non-streaming (a
+ * WorkManager worker has no UI to stream into). Triple-gated: a saved key (or a committed Custom
+ * server), [consent], and the brief instruction shared with the Swift twin. Returns null on any
+ * failure (no key, no consent, network, rate limit, empty reply) — the caller treats null as
+ * "brief unavailable"; never throws.
+ */
+ suspend fun generateBrief(
+ ctx: Context,
+ provider: AiProvider,
+ model: String,
+ consent: Boolean,
+ customBaseUrl: String = "",
+ customAuthHeader: CustomAiAuthHeader = CustomAiAuthHeader.BEARER,
+ includeSignals: Boolean = false,
+ ): String? {
+ if (!consent) return null
+ val key = AiKeyStore.read(ctx, provider)
+ if (key == null && provider != AiProvider.CUSTOM) return null
+ if (provider == AiProvider.CUSTOM && (customBaseUrl.isBlank() || model.isBlank())) return null
+ return runCatching {
+ val history = listOf(ChatMsg(role = "user", text = BRIEF_INSTRUCTION))
+ chat(ctx, history, provider, model, consent, customBaseUrl, customAuthHeader, includeSignals)
+ .trim()
+ .ifBlank { null }
+ }.getOrNull()
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Contextual suggestion chips
+ // ---------------------------------------------------------------------------------------
+
+ /**
+ * Contextual suggestion chips for the composer, derived from today's bands via
+ * [CoachSuggestions]. Reads only on-device `daysMerged` (the same merged read the context
+ * builder uses, so a live strap's scores under "my-whoop-noop" are not missed); pure,
+ * byte-identical to the Swift twin `AICoachEngine.suggestions`. Returns the stable generic
+ * fallback when there is no usable data. Best-effort: a repo failure yields the fallback,
+ * never throws.
+ */
+ suspend fun suggestions(): List = withContext(Dispatchers.IO) {
+ val days = runCatching { repo.daysMerged(activeStrapId()) }.getOrDefault(emptyList())
+ CoachSuggestions.suggestions(days.lastOrNull(), days)
+ }
+
+ // K7: Follow-up suggestion chips shown after each assistant reply. These are generic
+ // conversational follow-ups (not data-derived) so the user can dig deeper without typing.
+ // Byte-identical to the Swift twin's `AICoachEngine.followUpSuggestions`.
+ val followUpSuggestions: List = listOf(
+ "Tell me more about that",
+ "What should I do next?",
+ "How does today compare to this week?",
+ "Give me a specific action plan",
+ )
+
+ // K13: Inject the cached summary of the dropped middle into the first user turn of the
+ // windowed history, so the model sees continuity instead of a gap. Best-effort: when no
+ // summary is cached, returns the windowed list unchanged (the old gap behaviour).
+ private fun injectDroppedSummary(
+ windowed: List,
+ full: List,
+ ): List {
+ val summary = droppedSummary ?: return windowed
+ if (windowed.isEmpty()) return windowed
+ // Only inject when the window actually dropped messages (the full list is longer).
+ if (full.size <= MAX_HISTORY_TURNS + 1) return windowed
+ val firstUserIdx = windowed.indexOfFirst { it.role == "user" }
+ if (firstUserIdx < 0) return windowed
+ val first = windowed[firstUserIdx]
+ val annotated = first.copy(text = "$summary\n\n---\n\n${first.text}")
+ return windowed.toMutableList().also { it[firstUserIdx] = annotated }
+ }
+
// ---------------------------------------------------------------------------------------
// Context builder
// ---------------------------------------------------------------------------------------
@@ -254,8 +407,19 @@ class AiCoach(
val sleepH = d.totalSleepMin?.let { fmt1(it / 60.0) + "h" } ?: "-"
val hrv = d.avgHrv?.let { "${it.roundToInt()}ms" } ?: "-"
val rhr = d.restingHr?.let { "${it}bpm" } ?: "-"
+ // The stage breakdown and efficiency, which the coach could not see at all: a user asked why
+ // it said it had no access to sleep stages, and it was answering honestly — `rest 7.8h` was
+ // every word it got about a night. These sit on the SAME DailyMetric this line already reads.
+ // Always emitted, "-" when absent, like every other field: a night with no staging then says
+ // so, rather than the schema changing shape between days and inviting the model to read a
+ // missing field as a zero. Twin of the Swift `AICoach.dayLine`.
+ val deep = d.deepMin?.let { fmt1(it / 60.0) + "h" } ?: "-"
+ val rem = d.remMin?.let { fmt1(it / 60.0) + "h" } ?: "-"
+ val light = d.lightMin?.let { fmt1(it / 60.0) + "h" } ?: "-"
+ val eff = effPctOrDash(d.efficiency)
sb.append(
- " ${d.day}: charge $recovery, effort $strain, rest $sleepH, HRV $hrv, RHR $rhr\n"
+ " ${d.day}: charge $recovery, effort $strain, rest $sleepH, " +
+ "deep $deep, REM $rem, light $light, eff $eff, HRV $hrv, RHR $rhr\n"
)
}
@@ -307,11 +471,12 @@ class AiCoach(
val sb = StringBuilder()
// --- Strongest associations on the user's own logged days (recovery as the outcome) ---
- val behaviours = runCatching { journalBehaviours() }.getOrDefault(emptyMap())
+ val (behaviours, controls) = runCatching { journalBehaviours() }
+ .getOrDefault(emptyMap>() to emptyMap())
val days = runCatching { repo.daysMerged(activeStrapId()) }.getOrDefault(emptyList())
if (behaviours.isNotEmpty() && days.isNotEmpty()) {
val recoveryByDay = days.mapNotNull { d -> d.recovery?.let { d.day to it } }.toMap()
- val ranked = runCatching { EffectRanker.rank(behaviours, recoveryByDay, "Charge") }
+ val ranked = runCatching { EffectRanker.rank(behaviours, controls, recoveryByDay, "Charge") }
.getOrDefault(emptyList())
.take(3)
if (ranked.isNotEmpty()) {
@@ -340,16 +505,22 @@ class AiCoach(
return sb.toString().trim().takeIf { it.isNotEmpty() }
}
- /** Behaviour → set of "yyyy-MM-dd" days it was logged "yes" (imported ∪ native), for EffectRanker. */
- private suspend fun journalBehaviours(): Map> {
+ /** Behaviour → the days it was logged YES and the days it was logged NO (imported ∪ native), for
+ * EffectRanker. Kept apart: a day with no journal row for a question belongs to neither, so an
+ * unanswered day is never counted as a No — see [EffectRanker.effect]. */
+ private suspend fun journalBehaviours(): Pair