Repository navigation
Conversation
ryanbr#1849) Both WHOOP CSV importers treated `skin_temp_f` as a plain alias of `skin_temp_celsius` and stored the value unconverted. A Fahrenheit export therefore landed ~92.3 in a column the whole app reads as °C — reading as a lethal fever, poisoning the baseline, and (for a user on Fahrenheit) getting converted AGAIN to ~198 °F. The fix reads each key as its OWN column and converts the Fahrenheit value on the way in: `(f − 32) × 5/9`. `skin_temp_celsius` wins when both are present (a Celsius export is the canonical form). The deviation path is unaffected — `skin_temp_f` only ever appears on the absolute column. Both platforms move together to keep the stored value byte-identical: - Swift: `WhoopExportImporter.parseCycles` - Kotlin: `WhoopCsvImporter.parseCycles` Tests on both platforms pin the conversion (91.58 °F → 33.1 °C, matching the existing Celsius fixture) and guard the Celsius regression path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ryanbr#1503) Every identity NOOP wrote into Apple Health embedded the active strap id (`noop:<deviceId>:<kind>:<identity>`). That id is not durable — it changes on a re-pair or serial-based identification — and when it changes, the delete-then-write reconciliation can no longer find the old records (its predicate is built from the id active NOW). The stranded records stay in Apple Health permanently as duplicates, with no way to clean them up from inside NOOP. The fix drops the id segment entirely, matching the Android twin (`HealthConnectWriter.clientRecordId` carries no device id — `noop-workout-<startTs>`). The key becomes a natural key: `noop:<kind>:<identity>`, stable across strap lifecycle changes. Three write paths affected: - vitals: `noop:<metricId>:<day>` - sleep: `noop:sleep:<startTs>` - workouts: `noop:workout:<startTs>` Heart-rate is immune (it already deletes by date range, not external UUID) and is unchanged. A one-off migration sweep (`migrateStrandedHealthRecords`) clears records written under the OLD id-keyed scheme. It runs once per install (gated by UserDefaults), BEFORE the new-key writes, using `HKSource.default()` + date range — the same pattern the HR path uses — to reach records the new key-based delete predicate can never find. The normal writes then re-add everything under the stable keys. The key generation is extracted into pure, testable helpers in `HealthWriteback` (`Packages/StrandImport`) so the natural-key format is pinned by `swift test`, not just by reading the app-target code that can't be unit-tested (HealthKit itself can't be). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Thanks @evoveotech — the key change is right and the risky parts are handled. I checked each of these rather than reading the summary, because this one deletes from Apple Health:
The one-shot flag is set even when the sweep did nothingEach delete is guarded by But partial authorization is a supported, documented state — the resume check is
So this sequence loses the migration permanently:
The same ending arrives if a This is the "fails to NOTHING rather than to something wrong, which is why it kept reading as already done" pattern from the #1919 revert. It is worse here than there: a one-shot migration has no second chance, and the records it was meant to remove cannot be reached by anything else afterwards. Smallest fix that closes it: set the flag only when the sweep actually ran for the types that were authorized — or record it per type, so a later grant still gets swept. Either keeps the "once per install" intent while letting an install that could not complete the sweep finish it later. Minor
|
… one boolean (ryanbr#1503 review) The one-shot sweep flag was set unconditionally at the end of the migration, even when the sweep did nothing for a type — either because authorization was partial (sleep granted, workouts declined) or because a `deleteObjects` call threw (every delete is `_ = try? await ...`, so a failure is indistinguishable from a successful no-op). Once the flag was set the sweep never ran again, so the stranded records it was meant to remove stayed in Apple Health forever, with no in-app way to clear them. Per-type completion tracking fixes it: each write type is marked swept only when its `deleteObjects` returned a result (succeeded, even if it deleted zero records). A type that was declined or whose delete threw is left un-swept, so a later authorization grant or a successful retry finishes the migration instead of abandoning the records. The decision helpers are extracted into `HealthWriteback` (StrandImport) so they are unit-tested without HealthKit (HealthKit itself can't run under `swift test`). The app-target migration loads/persists the swept set from UserDefaults and records only the types whose delete succeeded this run. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Thanks for the review — both points addressed in The one-shot flag is set even when the sweep did nothingSwitched from a single boolean to per-type completion tracking. Each write type is marked swept only when its The partial-authorization sequence you described now completes across runs:
The decision helpers ( Minor:
|
Summary
Ref #1503 (deliberately not auto-closed; the issue can be closed deliberately on merge).
Every identity NOOP wrote into Apple Health embedded the active strap id (
noop:<deviceId>:<kind>:<identity>). That id is not durable — it changes on a re-pair or serial-based identification — and when it changes, the delete-then-write reconciliation can no longer find the old records (its predicate is built from the id active now). The stranded records stay in Apple Health permanently as duplicates, with no way to clean them up from inside NOOP.The fix
Drop the id segment entirely, matching the Android twin (
HealthConnectWriter.clientRecordIdcarries no device id —noop-workout-<startTs>). The key becomes a natural key:noop:<kind>:<identity>, stable across strap lifecycle changes.Three write paths affected:
noop:<metricId>:<day>noop:sleep:<startTs>noop:workout:<startTs>Heart-rate is immune (it already deletes by date range, not external UUID) and is unchanged.
One-off migration (per-type completion tracking)
A one-off sweep (
migrateStrandedHealthRecords) clears records written under the OLD id-keyed scheme. It runs before the new-key writes, usingHKSource.default()+ date range — the same pattern the HR path uses — to reach records the new key-based delete predicate can never find. The normal writes then re-add everything under the stable keys.Completion is tracked per-type (not a single boolean), addressing the review on the first push: a type is marked swept only when its
deleteObjectsreturned a result (succeeded, even if it deleted zero records). A type that was declined (partial authorization) 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 (strandedSweepPending/strandedSweepResult) are extracted intoHealthWritebackso they are unit-tested without HealthKit.Pure, testable key helpers
The key generation is extracted into pure, testable helpers in
HealthWriteback(Packages/StrandImport) so the natural-key format is pinned byswift test, not just by reading the app-target code that can't be unit-tested (HealthKit itself can't be).Tests
HealthWritebackTests:testVitalKeyHasNoDeviceIdSegment— pinsnoop:restingHeartRate:2026-08-27, asserts nomy-whooptestSleepKeyHasNoDeviceIdSegment— pinsnoop:sleep:1700000000testWorkoutKeyHasNoDeviceIdSegment— pinsnoop:workout:1700000000testKeysAreDeterministic— same inputs → same keytestVitalKeysDifferByMetric— no collision across metrics on the same daytestStrandedSweepPendingForAuthorizedUnsweptType— authorized + unswept → pendingtestStrandedSweepSkipsAlreadySweptType— already swept → not retriedtestStrandedSweepNeverAttemptsDeclinedType— declined type never pending (partial-auth)testStrandedSweepResultRecordsSuccessfulDelete— success → marked swepttestStrandedSweepResultDoesNotRecordFailedDelete— failure → NOT marked (retried)testStrandedSweepCompletesAcrossRunsAfterLateAuthorizationGrant— late grant still swepttestStrandedSweepRetriesFailedDeleteOnNextRun— failed delete retried next runTest plan
swift testoverPackages/(StrandImport tests — the pure key + sweep helpers)app-build.ymlon-demand (the iOS app target compiles the newHealthWriteback.*call sites inHealthKitBridge)doc_comment_lint(no new detached doc comments)Generated with Devin