Skip to content

Caffeine: import intakes from Apple Health (#949) - #985

Merged
ryanbr merged 4 commits into
mainfrom
caffeine-health-import-949
Jul 31, 2026
Merged

Caffeine: import intakes from Apple Health (#949)#985
ryanbr merged 4 commits into
mainfrom
caffeine-health-import-949

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 30, 2026

Copy link
Copy Markdown
Owner

The caffeine half of #949. Replaces #975, which GitHub auto-closed when its base branch was deleted on merging #974 — same branch, same commits, now targeting main.

The caffeine window (#526) already stores exactly what a HealthKit caffeine sample carries — a timestamp and an optional mg — so a coffee logged elsewhere can feed the decay estimate instead of being typed in twice.

A sample query, not a day sum

Water uses HKStatisticsCollectionQuery day buckets. Caffeine cannot: the estimate decays each intake from its own time, so a day total would collapse a 7am coffee and a 9pm one into a single number that answers no question the card asks.

Correcting something I wrote on the issue: I said the mg figure "would not feed anything today". That was wrong — it feeds #526's half-life estimate. What is timing-only is DoseResponseEngine, a different feature. I conflated the two.

Dedup is harder here than for water

Water is one day total that can simply be replaced. Caffeine is a list of individual events. So an imported intake carries its HealthKit sample UUID in a new optional externalId, and a sync replaces the imported subset wholesale, leaving hand-logged intakes untouched — giving the same two properties: re-importing is idempotent, and an intake deleted in the source app disappears here.

externalId is optional, so JSON written before this change still decodes, with every existing intake correctly reading back as hand-logged rather than as an import a later sync would feel entitled to delete. There is a test for exactly that.

Imported intakes are not the user's to edit

remove and clearAll skip them and the UI offers no control, because the next sync re-reads the same window and would bring the entry back — offering the button would be offering something NOOP cannot honour. The row shows Apple Health instead.

One shared store

CaffeineLogStore gains a shared instance and the card observes it rather than owning its own; two instances over one UserDefaults key would leave the card publishing a stale array while imported intakes sat invisible on disk. Follows LiquidPowerMonitor exactly — the same @MainActor final class … ObservableObject + static let shared + @ObservedObject shape already used across the Liquid views.

Android is deliberately NOT matched

Health Connect has no caffeine-only scope: caffeine is a field on NutritionRecord behind READ_NUTRITION, which grants the user's entire food log. Apple exposes .dietaryCaffeine as its own narrow type. Asking every Android user for their whole nutrition history to read one number is not a trade worth making quietly, so the manifest is untouched and the gap is documented. (The water half is matched — Health Connect has a narrow READ_HYDRATION.)

Two defects found re-reviewing this, both fixed

Imported intakes got a new identity every sync. CaffeineIntake.id defaults to UUID() and the bridge rebuilds each intake from the samples every pass, so the same coffee was a different row each time — defeating replaceImported's "nothing changed" guard (a pointless rewrite and republish per sync) and handing ForEach a wholly new list. Now keyed on the sample uuid, which is stable. My own test could not have caught this: it reused a single value for both imports so the ids matched trivially. It now builds a fresh intake per import, plus an explicit equality test.

A failed query deleted every imported intake. collectCaffeine discarded the error, so a failure returned an empty array and replaceImported cleared the set. It returns nil on failure now; only a genuine empty result clears anything.

Verification — read this part

  • 19 tests in StrandTests/CaffeineImportTests.swift: idempotency, identity stability, the vanished-sample case, hand-logged intakes surviving a sync, the backward-compatible decode, the not-editable rules, and a persistence round-trip.
  • No CI runs them. app-build.yml is disabled and StrandTests executes nowhere. They are written and unexecuted — I am not claiming they pass, only that I could not run them.
  • All four changed Swift files pass swiftc -parse. That is a syntax check, not a type-check; SwiftUI/HealthKit are unavailable on this Linux host.
  • Not device-tested: the HealthKit read path can only be proven on hardware with real caffeine samples.

Honest summary: the logic is tested by construction and unexecuted, and the app target is uncompiled. Worth a local xcodebuild (or a one-off app-build.yml run) before merging — more so than #974, since there is no Android leg here to corroborate it.

ryanbr added 3 commits July 30, 2026 13:07
The caffeine window (#526) already stores exactly what a HealthKit caffeine
sample carries — a timestamp and an optional mg — so a coffee logged in
another app can feed the decay estimate instead of being typed in twice.

A SAMPLE query, not the day-bucketed one water uses: the estimate decays each
intake from its own time, so a day total would collapse a 7am coffee and a 9pm
one into a number that answers no question the card asks.

Dedup is harder than water's. Water is one day total that can simply be
replaced; caffeine is a list of individual events. So an imported intake
carries its HealthKit sample UUID in a new optional `externalId`, and a sync
replaces the imported SUBSET wholesale, leaving hand-logged intakes alone.
That keeps a re-import idempotent instead of logging a second copy of every
coffee, and makes an intake deleted in the source app disappear here too.
The field is optional so existing stored JSON still decodes, with every
existing intake correctly reading back as hand-logged.

Imported intakes are not editable here: remove/clearAll skip them and the UI
offers no control, because the next sync would re-read the same window and
bring the entry straight back. Remove it where it was logged.

The store gains a `shared` instance — the card used to own its own, which was
fine while every write came from the card, but two instances over one
UserDefaults key would leave the card publishing a stale array while the
imported intakes sat invisible on disk. Follows the LiquidPowerMonitor
pattern already used for @mainactor ObservableObject singletons.

ANDROID IS DELIBERATELY NOT MATCHED. Health Connect has no caffeine-only
scope: caffeine is a field on NutritionRecord behind READ_NUTRITION, which
grants the user's entire food log. Apple exposes .dietaryCaffeine as its own
narrow type. Asking every Android user for their whole nutrition history to
read one number is not a trade this app should make quietly, so the manifest
is untouched and the gap is documented instead.

Tests: 15 in StrandTests covering idempotency, the vanished-sample case, the
backward-compatible decode, and the not-editable rules. No CI runs them —
app-build.yml is disabled and StrandTests executes nowhere.
…ead error

Two defects found re-reviewing the PR.

The intake id was freshly minted on every sync. CaffeineIntake.id defaults to
UUID(), and the bridge rebuilds every intake from the HealthKit samples each
time, so the same coffee got a new identity on every pass. That defeated
replaceImported's "nothing changed" guard (a pointless JSON rewrite and
republish on every sync) and gave ForEach an entirely new set of rows to
rebuild each time. The sample's own uuid is stable, so use it.

My own test could not have caught this: it reused one CaffeineIntake value for
both imports, so the ids matched trivially. It now builds a fresh intake per
import, the way the bridge does, plus an explicit test that two intakes built
from one sample are equal.

Second, collectCaffeine discarded the query error, so a failed read returned
an empty array and replaceImported deleted every imported intake — the same
class of bug as the hydration one in the previous commit, and worse here
because the caller's whole job is to replace the set. It now returns nil on
failure, and only a genuine empty result clears anything.
The last instance of the pattern that has bitten four times today: the cast
used `?? []`, so an unexpected sample type would read as "no caffeine" — and
because the caller replaces the imported set wholesale, that would silently
delete every imported intake. Vanishingly unlikely on a query typed to a
quantity type, but it is the same failure shape as the error check sitting
beside it, so it now shares that check.
@ryanbr

ryanbr commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

Re-reviewed against current main (11 merges have landed since this opened).

Still applies cleanly, and nothing has drifted under it. CaffeineLog.swift was last touched in June, CaffeineLogCard.swift on 1 July, and HealthKitBridge.swift's most recent change is this PR's base (#974). So the four files are exactly as this branch expects.

Both earlier re-review fixes are intact — the sample uuid as the intake identity, and collectCaffeine returning nil on a failed query rather than an empty array.

One tightening, pushed. The remaining (samples as? [HKQuantitySample] ?? []) was the last instance of the pattern that has bitten four separate times today: an unexpected sample type would have read as "no caffeine tonight", and since the caller replaces the imported set wholesale, that would silently delete every imported intake. The cast is now part of the failure test rather than a fallback. Essentially impossible on a query typed to a quantity type — but it is the same shape as the error check sitting beside it, and I have stopped giving that shape the benefit of the doubt.

A dependency worth recording: this PR only works because #974 landed first. Adding .dietaryCaffeine changes readTypeSignature, which is what makes #974's requestNewReadTypesIfNeeded re-prompt existing users for the Caffeine scope. Without that mechanism this would have been silently dead for everyone who had already connected Apple Health — the exact failure I found in #974 this morning. Merged in the right order by luck rather than design.

The verification gap is the real story here

Only two checks run on this PR: check and doc-comments. No test (…) jobs fire at all, because nothing under Packages/** changes. So:

  • the 19 tests in StrandTests/CaffeineImportTests.swift execute nowhere;
  • and no compiler has seen HealthKitBridge.swift, CaffeineLog.swift or CaffeineLogCard.swiftapp-build.yml is disabled, and swiftc -parse is a syntax check, not a type-check.

That is not a caveat about this PR so much as a statement that this PR has no executable verification of any kind. Every claim in it rests on reading. Given today produced three self-inflicted defects in a PR I had already re-reviewed twice, I would not treat my own reading as sufficient here.

Recommendation unchanged from when I opened it: this is the one that most wants a compile before merging — either a local xcodebuild for Strand + NOOPiOS, or a one-off app-build.yml run. Everything else about it is ready.

@ryanbr
ryanbr merged commit e0237c3 into main Jul 31, 2026
2 checks passed
@ryanbr
ryanbr deleted the caffeine-health-import-949 branch July 31, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant