Skip to content

SportMode - Loop Glance complications - #2563

Draft
jeremybarnum wants to merge 90 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/next-dev-complications-v2
Draft

jeremybarnum wants to merge 90 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/next-dev-complications-v2

Conversation

@jeremybarnum

@jeremybarnum jeremybarnum commented Oct 9, 2026 •

Copy link
Copy Markdown

Sport Mode: "Loop Glance" complications. Stacked on #2545 and the WidgetKit complications PR, #2562, which are merged in as they move; the net diff against #2545 shows this PR's changes together with #2562's.

What's offered. The face editor asks for a slot's shape first, so each shape is its own widget with its own options. Every option leads with the reading; units and the reading's arrow label the values (112↗, 3m, 1.2U, 10g, +0.40U/h, →140), with no titles.

  • Corner: BG with age, IOB or COB, or two of them (112↗ 1.2U 3m); a watch or phone icon in the tip shows who holds the pod.
  • Inline (top line, Utility's bottom arc): BG with combinations of age, IOB and COB, the richest adding an active override (112↗ 3m · 1.2U · 10 g · 🏃 70% 140).
  • Circular: the loop ring and BG over age, IOB or COB; on faces with a bezel, the label carries what the circle leaves out.
  • Rectangular: Big BG, balanced (BG large, IOB and COB beneath), or Glance (every value, including eventual and temp).

Slots drawn in capitals space the grams (10 g), so they never read as "10G". Values dash after 15 minutes.

Source of truth is the glance, so a complication can never disagree with the Start screen: during a loan, the watch's own loop; otherwise the phone's context, which on next-dev carries the watch's own G7 reading.

Refresh: what we believe, from the watch's system logs (Apple documents none of it):

  • App in front: every reload is free and every complication rebuilds.

  • App in the background: a reload is free only while the app holds a Bluetooth connection, and at most once every 300 s per app. The free reload rebuilds one complication, in practice the one longest on the face. Every other Loop complication is refused ("within throttle period") and, with no budget to fall back on, stays as drawn until the app is opened. Registering separate widget kinds doesn't change this: tested, the limit is per app.

  • Ages and dashes advance from timeline entries without any reload.

  • Connected moments: the G7 link, about 1–4 s after each reading, all day. During a loan there's also the pod exchange, about 5–15 s after the reading.

  • What the publisher does: it asks at the first connected moment at least 300.1 s after the last reload the widget actually built, checks whether it built, and retries once in the next window. In front it asks only if the face hasn't drawn the current reading.

  • So in practice: one Loop complication stays live in the background, and the options are built to put what matters into that one slot. In an overnight loan with a real pod, 58 of 70 readings (83 %) reached it within a minute.

  • Not yet known: whether "longest on the face" is the actual rule for which complication wins; how the phone's own complication updates (off-loan) interact with this.

  • WatchLoopManager+Display's override label moves to a static overrideLabel(for:) (same output) so the publisher can use it.

  • Tests: GlanceComplicationTests (formatting and units, staleness, the per-shape options, the refresh policy's windows, spacing, retry and in-front rule).

Related PRs

🤖 Generated with Claude Code

Jeremy Barnum and others added 30 commits October 1, 2026 09:39
The phone lends the Omnipod to the watch. The watch runs Loop itself (its
own G7 glucose, its own stores, the stock algorithm) and doses the pod until
it hands it back. The phone stays the home of the user's data and takes the
pod back on demand.

Loan protocol (LoopCore/PodLoan, compiled into the phone, the watch and both
test targets):
- LoanMessages (grant, hand-back, revoke, acks, status reports),
  LoanRecords (the dose, carb and override records that move between
  devices) and LoanTransport over WatchConnectivity.
- The envelope carries a protocol version and throws on a mismatch.
  Same-version field changes ride capability flags, because the two apps
  install separately and a watch newer than its phone is routine.

Phone, the lender (PodLoanPhoneController and its extensions,
LoanReconciler):
- Grant: releases the pod's connection and sends its state and the therapy
  settings. A standing copy on the watch lets the watch start without the
  phone.
- Hold: while the pod is out, the phone's loop computes but never enacts.
- Records from the watch are stored idempotently by sync identity.
  Reconciliation audits the pod's delivered total against the books, and
  opens the loop and books a placeholder for insulin it cannot explain.
- Reclaim and settle: a hand-back or a forced reclaim, a status read to
  verify, then a resumed loop. A silent watch is warned about, never taken
  from.
- Hooks in stock files: DeviceDataManager, LoopDataManager (the enact
  interlock), WatchDataManager, AlertManager, NotificationManager,
  TemporaryPresetsManager, StatusTableViewController. SettingsProvider and
  LoopUpdateContext move to their own files so the watch can compile them.

Watch, the borrower (WatchApp Extension/StockLoop):
- StockLoopStack and StockLoopSession: the watch's own glucose, dose and
  carb stores, a G7 manager reading the sensor directly, and a pump only
  during a loan.
- WatchLoopManager: the Loop cycle with the stock algorithm and the granted
  settings. Missing settings deny dosing; nothing is defaulted.
- PodLoanWatchController: takeover, hand-back, revoke, resume after a
  relaunch, and a start from the standing copy when the phone cannot be
  reached, with a prompt when it returns.
- An HKWorkoutSession keeps the app running across takeover and hand-back.
- The glance, carb and bolus entry during a loan, wrist alerts for a stalled
  loop and a stuck hand-back, a diagnostics page and a device log.

Tests: LoanProtocolV2Tests, LoanBooksHarnessTests and
PodLoanPhoneControllerTests in LoopTests, and a WatchAppTests target that
reaches the watch extension's own code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… G7 fixes

Phone, while the pod is loaned to the watch:
- A "Sport Mode Active" banner in the status rows; tapping it offers the
  reclaim, or the settling notice while a reclaim is in flight.
- Add Carbs, Bolus and Presets are disabled and dimmed.
- The loop circle stays fresh with no minutes caption, and its dialog says
  the watch is running the loop.
- Charts show history only: no forecast or "Eventually", blank Active
  Insulin and Active Carbs values, IOB and COB curves cut at now. A loan
  starting or ending refetches everything.

Automatic bolus on the watch:
- The grant carries the phone's dosing strategy instead of forcing
  tempBasalOnly, and the watch runs the matching recommendation type.
- A cycle whose temp needs no command still enacts its automatic bolus;
  boluses are rounded to the pump's volume and drive the glance's delivery
  bar.
- LoanDoseRecord.automatic carries the flag home, so the phone stores watch
  automatic boluses as automatic.

Feature flag: sportModeEnabled (SPORT_MODE_ENABLED, default off) gates the
watch's Sport Mode and diagnostics pages.

Watch G7:
- While the watch searches for a sensor it has never connected to, the glance
  and diagnostics ask the user to keep Loop open, and a pre-scheduled
  "Sensor Not Found" alert fires if the app stays in the background for ten
  minutes.
- A persisted sensor is past its life only after its reported session end,
  or 15.5 days when the length is unknown, so a 15-day sensor in its grace
  period is no longer discarded at launch.
- Bluetooth wake tasks are never completed twice; a redelivered task threw
  mid-pairing and crashed the app.
The glance, diagnostics page and sensor-search alert read
watchNeedsCodeFor and watchIsSearching from the stack's G7CGMManager and
observe G7CGMManager.watchStatusDidChange. The diagnostics page no longer
offers the re-lodge picker.
… stock

Device hand-off contract:
- The grant carries the pump's exported configuration
  (SharedDeviceConfiguration) instead of its raw state. LoanProtocol.version
  is 3, so a version-2 peer is refused rather than guessed at.
- WatchDeviceManagers builds the pump and the CGM through init(adopting:),
  keyed by plugin identifier. The CGM is built from the phone's export on
  the watch context and rebuilt only when that export changes.
- Gone with it: the watch's edits to the pod's raw state, its handle cache,
  the device-specific loan calls and the G7 state reads. Also gone: the
  launch-time G7 past-life discard and the forgotten-sensor guard. G7SensorKit
  keeps a passed sensor itself and knows each sensor's own session length.
- Takeover readiness comes from deviceManagerControlDidBecomeReady. The
  pre-Start "keep your wrist up" note asks takeControlNeedsSearch(adopting:)
  of the standing copy.

Loan state:
- PodLoanPhoneState and PodLoanWatchState hold each side's persisted loan
  fields as one value, saved before any send that depends on them. The
  watch's loop mode, correction model and last cycle are one WatchLoopState.
  The loaned pump's state is saved the way stock saves a pump manager's.
- Fixed on the way: the grant's audit base was saved under the previous
  loan's epoch; the watch never saved the phase its launch normalised; a
  seized loan's reunion token was saved after its active phase.
- Injectable storage seams for tests.

Hand-off behaviour:
- Take-back: a deferred force is satisfied by the close, and a yield closes
  the settle window.
- Offline start: a confirm with the phone back is a normal request, and a
  grant or a new Start withdraws the offer.
- First contact with a new pod asks for the wrist up and says when it can
  come down.
- No whole-loan workout session. The watch's bolus progress comes from the
  pump manager's reporter. Wrist overrides are journaled again.

Alerts during a loan:
- Glucose alarms on the wrist: stock GlucoseAlertManager compiled into the
  watch, built from the phone's alert settings carried in the grant, fed
  direct G7 readings and the watch cycle's forecast.
- A pod fault the watch reports gives one phone notice per fault, with the
  kit's own fault text (localizedInoperableDescription).
- OK or dismiss on the loaned pump's wrist alert acknowledges it on the pump,
  which is what stops an Omnipod beeping.
- Only what is critical on the phone becomes time-sensitive on the wrist.
  The loan's own notices are time-sensitive when nobody is dosing or insulin
  is booked wrong, and normal when they are routine.
- Any behaviour-relevant settings change, glucose alert settings included,
  refreshes the watch's standing copy at once.

Back to stock and lean:
- SPORT_MODE_ENABLED switches Sport Mode off at the entry points on both
  apps, not just its pages.
- Back to stock: the configurable development team, project settings (eager
  linking, the watch simulator's x86_64 exclusion), two stock-bug fixes in
  LoopAppManager.
- Removed: code only earlier test builds needed (migrations, the iCloud log
  mirror, a demo gallery), the log-only start gate, the phone's residual
  history, unused device-kit imports, Siri from the watch entitlements, and
  a test file for a feature not in this tree.
- Comments state intent in a line or two. Comments, log strings and
  identifiers carry no internal tags; temp-cancel reasons and placeholder
  sync identifiers have device-neutral names; the phone's string catalog
  matches its strings.

Tests: one for each change above; racy tests wait on their condition; loan
tests no longer write into the app's real Documents or notification centre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Gate: the phone loop's existing loan hook (`isPumpConnectionReleased`) now reads the
  controller's `holdsAutomaticDosing`, true while the pump is released OR a force-reclaim audit
  is still owed. Before, `takeControl()` lifted the only gate at the force reclaim, so the phone
  could enact a temp or automatic bolus on books missing the watch's insulin until the audit ran.
- The owed audit is already persisted state, so the hold survives a relaunch; the settle
  window's five-minute ceiling still ends it by opening the loop.
- The hold lifts after the verdict is applied, so a loop-open on a breach is dispatched first.
- Test: the dosing-hold test asserted only the notification pause; it now asserts the gate the
  loop reads, and fails against the release-only gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A wrist carb is now stored on the watch and journaled under one identity, its event ID,
  which is what the phone stores it under. Before, the watch's store minted its own sync ID, so
  deleting a carb an earlier interim drain had committed missed on the phone and the carb stayed.
- Wire unchanged: the delete record's existing `syncIdentifier` now carries the right value. No
  protocol version bump; an older phone matches it the same way.
- The phone's match moves into `DeletedCarb.matches`, shared by the store wiring and tests.
- Comments that said carbs have no identity now give the real reasons.
- Tests: a watch test from carb entry to delete record to the phone's match; a phone test for a
  carb committed by an interim drain and removed by a later delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The grid-delay arm is gone from G7SensorKit; remove the tests that
  pinned its arithmetic and the arm toggle.
- Pin the remaining hold decision through relodgeHold(sinceLinkUp:).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- needsCodeForSensor, isSearchingForSensor and G7CGMManager.statusDidChange
  replace the watch-prefixed names in the sensor-search alert, the glance
  note and the diagnostics page.
- The diagnostics page no longer reads the G7 bluetooth manager's
  isConnected and isScanning, which wait on its queue; it shows what the
  CGM manager publishes: last reading, code needed, searching. The "link"
  row goes.
- The "Reconnect sensor" button goes with the kit's reconnect action.
- Tests: a refused connect is retried after the back-off every time (no
  stand-down after two); the watch's bluetooth manager carries the
  acquisition arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LoanReconciler.expectedInsulin takes the pulse size as a parameter; the
  hard-coded Omnipod 0.05 U is gone. Rate records are still floored to
  whole pulses, now of the lent pump's size.
- The phone controller reads it from the lent pump through
  PumpDeliveryOdometer, which it already requires to lend. With no pump
  there is nothing to judge against: the force-reclaim audit takes its
  existing "cannot verify" path (the loop opens), a checkpoint is skipped,
  and a hand-back sets no pending audit. None of these could reach a
  verdict before without the phone's own pod read, so no outcome changes.
- The hand-back log's pulse-floored drain total uses the same size in place
  of its own x20/20 rounding.
- The watch's check of a seized loan's standing copy takes the size from
  the pump it just read.
- Behaviour is unchanged for Omnipod: the pod reports 0.05 U.

Tests: the reconciler, harness and phone-controller tests and the test pump
use 0.05 U.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The watch no longer splits G7's sync identifier to match a relayed phone
  reading against its own reading of the same sample. The watch's adopted
  G7 manager takes the sensor's activation from the phone's export, so both
  devices name a reading alike, and both watch paths write to the one
  store, under its one provenance. Stock GlucoseStore.addGlucoseSamples
  already skips a sample whose sync identifier that provenance has stored.
- The relay path counts a reading as delivered only when the store kept it.
- Narrow edge: if the phone exports before it knows the sensor's
  activation, the watch latches its own estimate, and the two names differ
  until the next export; until then a reading both devices deliver can be
  stored twice.

Tests: a relayed phone reading and the watch's own reading of the same
sample, read through the adopted G7 manager, are one stored row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Carb and glucose window: stock's 12 h plus a minute, not 10 h. Stock reads it from
  `LoopConstants.maxCarbEntryPastTime`, which is phone-only, so the value is repeated.
- ISF and override history start at `min(neededSensitivityTimeline.start, carbsStart)`, as in
  stock. Glucose can start later than the oldest carb (a CGM gap, or the watch's 4 h glucose
  cache), and CarbMath traps on a carb the ISF timeline does not cover. An override over that
  carb now scales its ratio as well.
- Still different from stock `fetchData`, on purpose: the pump-data recency gate lives in the
  fetch; doses are trimmed per dose (no forward credit) and ongoing doses are not projected; no
  pre-meal "ending now" for a carb-entry bolus and no high-needs suspend floor; missing
  settings throw instead of defaulting.
- Also different, not changed here: the automatic-bolus application factor is LoopAlgorithm's
  default 0.4, which is stock's value unless glucose-based application is on (a phone setting
  the grant does not carry); settings are the grant's snapshot projected over the window, not
  settings history.

Test: hourly ISF items, 3 h of glucose, a 6 h-old carb under a 50 % override that has ended.
The ISF timeline covers the carb, its ratio and ISF are doubled, and the algorithm runs (before:
CarbMath trap, confirmed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `StockLoopStack` sets `TemporaryScheduleOverrideHistory.relevantTimeWindow` to 24 h when it
  builds the watch's history, enough for the algorithm's ~18 h lookback. The watch ran on
  LoopKit's 1-second default, so every glance or HUD refresh pruned an ended override and its
  minutes went unscaled from the next cycle: after an exercise preset, more insulin.
- The window is a LoopKit static. The phone sets its own (90 days) in `TemporaryPresetsManager`,
  which compiles into the iPhone `Loop` target only, so the two values never meet in one process.
- Each grant starts the watch's history over before applying the granted override. With ended
  overrides kept, an override the phone sends again (same identifier) would be stretched back
  over a wrist override the phone never heard of, and LoopKit traps on overlapping overrides
  (reproduced in the test). Starting over is what the 1-second window did in effect.

Tests: an override that expired 30 min ago still halves basal and doubles ISF and carb ratio
for its minutes after a refresh (before: unscaled); a grant re-sending an override after a
wrist override does not trap and keeps only what the phone sent (without the reset: trap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanGrant.overrideHistoryRaw`: the phone's overrides overlapping the last 24 h, ended ones
  included, as a binary plist of stock raw values inside the JSON envelope, like the override
  record. The raw form drops `actualEnd`, so an early end is folded into the duration (as
  `queryByAnchor` does) and deleted overrides are left out. The standing copy is the same grant,
  and `withEpoch` passes the field on for a phoneless start.
- The watch adopts it at intake, before the first cycle, each override at its original start
  and end, so an override that ended before Start still scales its minutes. A grant without
  the field (an older phone) leaves the watch as before: the active override only.
- Version skew: an optional field, no capability flag. An older watch's decoder skips the
  unknown key and behaves as today; an older phone sends nil. Neither side can misread the
  other, which is what the envelope's "optional means an older build" rule covers; no flag,
  no version bump.
- Standing-copy refresh: not extended. Its fingerprint already includes the active override,
  and every change the phone can make to its history (set, change, clear, natural end)
  changes that.
- Size: three overrides are 945 B of plist and add 1.3 KB to the grant's JSON (measured in
  the test), beside grants of about 25 KB and the 60 KB urgent-channel limit.

Tests: the phone's grant asks from 24 h back, folds an early end, drops a deleted override;
the field round-trips, is nil from an older phone, and an unknown grant field does not break
decoding; on the watch, an override that ended 2 h before Start halves basal and doubles ISF
and carb ratio for its minutes on the first cycle; a start from the standing copy keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The watch's override history is saved in the loop manager's persisted state on every
  change, through stock's `TemporaryScheduleOverrideHistoryDelegate` callback, as LoopKit's own
  Codable events: the 24 h window, ended overrides included, each with its actual end.
- A resume reads it back before the pump manager is rebuilt, so before the first cycle, and
  makes the override active in it current again, as stock's `TemporaryPresetsManager` does at
  launch. Before, a resumed loan dosed with no override at all: the history lived only in
  memory and the resume state held settings and flags.
- Only a resume reads it. An ordinary launch starts empty and the next grant replaces the
  history anyway. Opening the history through a SwiftData container, as the phone does, would
  also work but brings SwiftData into the watch extension.

Tests: a relaunch mid-loan brings back the active override and one that ended before it
(without the restore: no override, empty history); the resume checklist test now includes the
override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An override change journaled on the wrist now reaches the phone's apply path with its time:
  `LoanReconciler.OverrideChange` carries the record's date, `.set(_:at:)` and `.cleared(at:)`.
- The phone records the change in its override history at that time with stock's
  `recordOverride(_:at:)`, then sets `scheduleOverride` as before; the setter's own record then
  has nothing left to do. This is a sibling, `TemporaryPresetsManager.setScheduleOverride(_:changedAt:)`,
  in the loan wiring; the stock setter is unchanged. Before, a wrist clear drained at hand-back
  ended the phone's override at the hand-back, so the phone's next cycles treated the minutes
  in between as overridden (after an exercise preset, less insulin than it should). A wrist set
  already kept its own start date.
- Idempotency unchanged: committed event IDs and the syncIdentifier check. One guard added: a
  clear made before the phone's current override started does not end it. Recorded at its own
  time, such a clear would delete the newer override from the history.

Tests: end to end, an override set on the phone, a loan, a wrist clear an hour before the
hand-back: the phone's history ends the override at the clear; a replayed drain still applies
once, at the wrist's time; a clear older than the phone's override is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Therapy settings can't change on the phone during a loan, matching Carbs, Bolus and
  Presets; settings reach the watch only with a grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanOverrideTests` was missing from the phone gate's test list, so two of its tests had been
  failing unseen.
- `testOverrideReachesTheWristInTheGrant` asserted that the schedules ride in
  `LoopSettings.rawValue`. Stock's raw value has never carried them; the grant sends them in its
  settings supplement. The test also built a grant by hand, so it never exercised the phone.
  Replaced by `testTheGrantCarriesTheActiveOverrideInItsOwnField` in
  `PodLoanPhoneControllerTests`: the phone's own grant carries the active override, with its
  identity, in `activeOverrideRaw`, and the schedules in the supplement, not the settings blob.
- `testPhoneApplyIsIdempotentAcrossAReplayedDrain` cleared at the same instant the override
  started. The wire rounds dates to whole milliseconds, so about half the time the clear landed
  just before the start and the newer-override guard skipped it. The clear is now a minute later.
- Two comments still described the override riding in the settings blob; corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The loan controller drains the wrist's journal on its own queue and applied override changes
  from there, into `TemporaryPresetsManager`, which is main-actor state. The wiring now hops to
  main with `DispatchQueue.main.async`, as its other settings writes do, through a sibling of
  the setter, `setScheduleOverrideOnMain(_:changedAt:unapplied:)`.
- Async, so the controller's queue never waits on main. The main queue runs blocks in the order
  they were queued, so changes land in the order the wrist made them.
- Idempotency: the controller decides on its own queue from what the phone holds, which it read
  synchronously before. A change on its way to main is now held in a small locked list until main
  applies it, and the wiring's reader answers with the newest one, so a drain right behind
  another still sees the first one's change (otherwise a clear right behind a set would read "no
  override" and be skipped). Committed event IDs and the syncIdentifier check are unchanged.

Tests: two changes queued from a background queue apply nothing there and show as unapplied,
then apply on main, set before clear, with the history ending the override at the clear's time.
Fails when the apply runs on the calling queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanGrant.settingsHistory`: at the grant, the phone runs stock's own settings-history queries
  (`getBasalHistory`, `getInsulinSensitivityHistory`, `getCarbRatioHistory`,
  `getTargetRangeHistory`) over the 24 h up to it and sends the resulting timelines, as stock's
  `AbsoluteScheduleValue`s with glucose in mg/dL. The standing copy is the same grant, and
  `withEpoch` passes the field on for a phoneless start.
- On the watch, `WatchSettingsProvider` answers each history query from the phone's timeline for
  the part of the window it covers and from the granted snapshot for the rest, clipped so the
  result is continuous. Before, the snapshot was projected back over the whole window, so a
  basal, ISF or carb ratio change made on the phone shortly before Start was applied to doses and
  carbs from before it.
- A grant without the field (an older phone) leaves the provider as before, snapshot only.
- The history is saved with the granted settings, so a resume after a relaunch keeps it.
- No mid-loan update: Settings is disabled on the phone during a loan. The standing copy's
  refresh fingerprint already covers the schedules, so a settings change refreshes the copy and
  its history with it.
- Version skew: an optional field, no capability flag, no version bump, as for the override
  history. An older watch's decoder skips the key; an older phone sends nil.
- Size: a day with one change, basal in 6 blocks, adds 1.8 KB to the grant's JSON; with hourly
  basal, 3.1 KB (measured in the test). Grants are about 25 KB, under the 60 KB urgent limit.

Tests: built by stock's queries over a real settings store with every value changed 2 h before
the grant, the field round-trips, and is nil from an older phone; the phone's grant asks for
the 24 h up to it; on the watch, after a change 2 h before Start, basal, ISF, carb ratio and
target read the old values before it and the new ones after, continuously across the window, in
the provider and in the first cycle's input (both fail with the history ignored); a grant
without the field projects the snapshot back; the standing copy and a resume keep the history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`G7RelayDedupTests` built the phone's name for the relayed reading with
`t / 3600`. G7SensorKit computes the hours as minutes then hours, `t / 60 / 60`,
and the two round differently for about a quarter of timestamps. When they did,
the watch's own reading of the same sample got a different name, the store kept
both, and the test saw three rows. It failed 3 of 7 full runs.

Test-only: on devices both names come from G7SensorKit's one formula, and the
activation date reaches the watch exactly (property lists over WatchConnectivity
and in the watch's saved files keep full precision).

Checked: 40 of 40 runs pass; with the old formula, 12 of 40 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PodLoanWatchController keeps each pump kit's localState in its own file,
  PumpLocalState, keyed by managerIdentifier. It is saved wherever the
  manager's state is saved and is not cleared when the loan ends.
- Start and the first-contact note pass it to the adopt and to
  takeControlNeedsSearch. Resume still restores from saved state, as stock
  does.
- A state update that arrives after teardown (the release clearing an
  unproven handle) is saved too; that is the point.
- The CGM adopt passes nil. The phone side only updates its test double.
- Why: device state belongs in the manager's saved state, which the host
  keeps, not in a kit's own UserDefaults.

Tests: WatchAppTests 159 passed, including two new FirstContactNoteTests
(handle survives teardown and reaches the next adopt; an unproven handle
is dropped at release); phone loan suites 152 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch now runs stock's three algorithm passes, each fed as stock feeds
it, from a labelled copy of stock's fetchData.

- Automatic loop: running temps cut at now and a bolus in flight counted
  whole (was pro-rated); stock's input checks, adding future-dated glucose;
  temp rate rounded down by the pump manager (was nearest); stock's enact
  refusals, adding a faulted pod and a running manual temp; last loop
  completed moves only on a closed-loop cycle.
- Recommended bolus: a running temp credited through its end (was cut at
  now); a pre-meal preset ends when bolusing with carbs; no pump-data check;
  no longer changes what the screens show.
- Both dosing runs: a very-high-insulin-needs preset raises the suspend
  threshold.
- Display: stock's display run now feeds the glance (IOB, COB, eventual),
  the stock watch pages and the complication, replacing the watch's own IOB
  calculation. Diagnostics shows the loop run.
- Kept deliberately: enact follows the watch's loop switch; a
  recommendation older than five minutes is refused; the stall watchdog
  refreshes on any error-free cycle.

Tests: StockRunsTests (16, each sabotage-verified); four suites moved to
the new runs. WatchAppTests 175/175, phone gate 152/152.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The display run (glance IOB, COB and eventual, and the stock pages' context) now refreshes on stock's triggers: a change in the watch's carb, glucose or dose store, an override set or cleared, and a change of loop mode. Before, it ran only at the end of each cycle, at takeover, when the grant's glucose landed and on resume, so the glance could lag a wrist bolus or carb entry by up to a cycle.
- Each trigger runs the existing display pass and republish on the data access queue, with no debounce, as stock. The run reads the stores and writes none, so it cannot retrigger itself.
- Only while a pod is held: between loans a store change leaves the display run alone, as before (the context publish already required a pod).

Tests: StockRunsTests +3 (a dose store change and a carb entry each update the glance IOB/COB with no loop cycle; between loans nothing runs), each sabotage-verified. WatchAppTests 178/178.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch stores dosing decisions as stock does, to match stock and to
prepare for uploading from the watch. Until then they go home in one
file at hand-back and the phone uploads them. The phone's loop keeps
running during a loan without dosing; it no longer stores decisions for
those cycles.

Tests: StockRunsTests +6, PodLoanPhoneControllerTests +2,
LoanDosingDecisionTests (new, 4). WatchAppTests 184/184, phone gate 158/158.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch app now sets LoopLocalCacheDurationDays from the same build
setting as the phone (90 days). Without it the watch fell back to one day,
so decisions not yet sent home could be purged during a loan longer than
a day, or before a watch revived after a force reclaim sent them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An earlier test's controller can still post an alert into the shared
scheduler while these run; the failed-acknowledgement test then counted
two pending requests instead of one (1 failure in a full run). The tests
now look only at requests for the alert they presented.

Checked: 3 full runs 184/184; with the put-back removed, the failed-
acknowledgement test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch records the alerts it issues, acknowledges and retracts in
stock's AlertStore while it holds the pod, so the phone's alert history
covers the loan; a kit's alert lookups answer from it. The records go
home in the loan's history file at close (the decisions file, renamed);
the phone adds each once by sync identifier with its original dates,
through a new AlertStore method that starts stock's alert upload.

Tests: LoanHistoryTests (new, watch, 4), PodLoanPhoneControllerTests +1,
LoanHistoryIntakeTests (new, 1). WatchAppTests 188/188, phone gate 160/160.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the watch holds the pod it writes every device log line to stock's
PersistentDeviceLog as DeviceDataManager does, so the phone's device log
covers the loan (the throttled text log stays). The lines go home in the
loan's history file at close; the phone adds each once, comparing whole
lines, with its original time.

Tests: LoanHistoryTests +4, LoanHistoryIntakeTests +1,
PodLoanPhoneControllerTests +1. WatchAppTests 192/192, phone gate 162/162.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch app signs with $(LOOP_WATCH_ENTITLEMENTS), as the phone signs
with $(LOOP_ENTITLEMENTS). It defaults to empty, so with Sport Mode off the
watch signs with no entitlements, as stock does, and Browser Build's watch
app ID needs no new capabilities. Sport Mode builds set it to the watch
entitlements file (HealthKit for the workout session, time-sensitive
notifications for wrist alarms) next to SPORT_MODE_ENABLED.

Tests: WatchAppTests 192/192 with it set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No cycle after a wrist carb save, a carb delete or an accepted manual bolus;
the store observers already refresh the display. On the bench the carb-save
cycle auto-bolused for the meal before the manual bolus reached the pod.
A G7 reading runs a cycle only when the store kept something new (stock's
storedNewGlucose), and the shared 4.2-minute gate is claimed under a lock.
Tests: five new watch tests, sabotage-verified; StockRunsTests and G7RelayDedupTests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jeremy Barnum and others added 27 commits October 4, 2026 10:53
While a loan runs and the watch is heard from, the phone raises no low,
urgent low, high, predicted low or missed-meal alerts: the wrist raises
them, with the loan's doses and carbs the phone does not yet have. When
the phone notices the watch silent with this phone beside the body (the
existing Watch Not Reporting detector), its own alerts come back on.

GlucoseAlertManager and MealDetectionManager each gain an optional
alertsHandledElsewhere check, false unless a host sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s the default)

In the simulator's face editor every recommendation showed the same preview,
BG -> Eventual. chronod sent each intent with its own serialized metric (iob,
cob, ...) but App Intents resolved the AppEnum parameter to nil, so the
provider saw the default every time. Declaring the AppEnum whole in the widget
target did not change it. The parameter is now GlanceMetric's raw value as a
String; all ten presets render distinctly (verified by the provider's new
os_log line and on screen).

Watch suite 219/219.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compiles stock LoopError, LoopWarning and the PumpManagerError and
DosingDecisionStore extensions into the watch, and deletes WatchLoopError
with its text and issue mapping. Stored dosing decisions, and so
Nightscout, now carry stock's error ids (glucoseTooOld, pumpDataTooOld,
connectionError...) rather than every compute failure filed as
missingDataError. The cycle's verdict line judges compute against enact by
the stage that failed. As stock, a manual bolus is capped by the picker,
not checked again at enact, and a nil manual-bolus recommendation is "no
recommendation", not an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch's loop cycle was split in two: one function computed the
forecast and stored a recommendation on the manager, and a second read
that stored recommendation back and enacted it. Stock's
LoopDataManager.loop() does both in a single pass, passing the
recommendation along as a local value. This makes the watch match it.

- loop() now follows stock's sequence in one do/catch: compute the
  forecast and dosing decision, pick the automatic dose, enact it, and
  record the outcome. The recommendation goes from compute to enact as
  a local, so there is no stored copy that could go stale between the
  two steps.
- enactRecommendedAutomaticDose() becomes
  enactAutomaticDose(bolus:tempBasal:decisionId:), which takes the dose
  it enacts as arguments. It keeps the same refusals (no pump manager,
  pod inoperable, suspended, manual temp running, uncertain delivery),
  now returned as stock's LoopError.
- The recommendedAutomaticDose property, its expiry check and
  updatePredictedGlucoseAndRecommendedDose() are removed, because
  nothing reads a stored recommendation any more.
- The refusal tests in StockRunsTests call the new function directly
  with the temp basal they test.

No change to what is dosed. Watch tests 211/211 and the phone loan
gate 171/171 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rectangles: the metric's name and the loop's age on top, the value large,
BG -> eventual beneath for context (the loop's numbers when the value is BG).
Mini glance: the numbers line no longer shrinks below the override line.
Loop age reads in whole minutes ("now", "4m", "30m+") instead of WidgetKit's
ticking "33sec"; the timeline carries a mark at each minute, which costs no
reload budget. Circles: BG -> Eventual shows the reading over the eventual
(it showed only "-> 112", indistinguishable from Eventual); Override shows its
symbol over scale and target, centred (an empty caption pushed it low).

Watch suite 222/222.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LoopDataManager.swift ends with eight top-level declarations that do not
depend on the LoopDataManager class: AlgorithmDisplayState and seven
extensions on LoopKit and LoopAlgorithm types. The watch app does not
compile LoopDataManager.swift, so it carried labelled copies of them.

They move, unchanged, into a new file, Loop/Managers/
LoopAlgorithmSupport.swift, which both the Loop and WatchApp targets
compile, and the watch's copies are deleted:

- AlgorithmDisplayState
- NewCarbEntry.asStoredCarbEntry
- StoredDataAlgorithmInput: addingDose, addingGlucoseSample,
  addingCarbEntry, removingCarbEntry, predictGlucose
- Notification.Name: LoopDataUpdated, LoopRunning, LoopCycleCompleted
- StoredDosingDecision.LastReservoirValue.init?(_:)
- StoredDosingDecision.Settings.init?(_:)
- AutomaticDosingStrategy.recommendationType
- StoredDosingDecision.updateFrom(input:output:)

The only edit is that LastReservoirValue's extension is no longer
private, so the watch can use it. Each extension moves whole, so a
reviewer can check the new file against the removed lines.

Watch tests 211/211 and the phone loan gate 171/171 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fetchData gathers one algorithm run's inputs: doses, carbs, glucose,
the basal, sensitivity, carb-ratio and target timelines, overrides and
dosing limits. The watch carried a labelled copy of it, which had to be
re-synced by hand whenever stock changed it.

The body moves into StoredDataAlgorithmInput.fetch(...) in
LoopAlgorithmSupport.swift. LoopDataManager.fetchData keeps its
signature and calls it with the phone's sources, and the watch's
fetchData calls it with the watch's. The body changes only where it
read LoopDataManager's own properties, which are now parameters:

- the override history and active override (temporaryPresetsManager)
- the pump's insulin type (deliveryDelegate)
- integral retrospective correction and the application-factor
  strategy (UserDefaults; the strategy is built by the caller)
- the insulin model lookup, passed as a function

One difference in timing: the active override is read once when
fetchData is called, where the old body read it twice, after the
awaits for doses, carbs and glucose. The inputs now come from a single
snapshot.

An `isolation` parameter keeps the work on the caller's actor, so
LoopDataManager's fetch still runs on the main actor and the watch's
still runs off it.

The watch target now also compiles the three store protocols,
ApplicationFactorStrategy, ConstantApplicationFactorStrategy and
LoopConstants, all unchanged.

Watch tests 211/211 and the phone loan gate 171/171 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the watch raises the glucose alerts, the phone's
GlucoseAlertManager evaluates nothing, so no reading closes an episode
it opened before the loan. A Low raised before the grant therefore
stayed "in episode" through the loan, and with snooze off (the default)
it has no repeat, so the first Low after the phone took the alerts back
never sounded. High behaved the same; Urgent Low always repeats.

While alerts are handled elsewhere, the phone now forgets its Low, High,
Urgent Low and predicted-low episode state, as the watch does at the
start of each loan. Taking the alerts back starts from no episode.

Test: a Low raised before the alerts went elsewhere does not silence
the next one (fails without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone's Algorithm Experiments include a glucose-based bolus
application factor. The grant carried the integral retrospective
correction setting but not this one, so the watch always used the
constant factor. With the experiment on, the wrist's automatic boluses
were about twice the phone's fraction near target.

The grant now carries glucoseBasedApplicationFactorEnabled next to
integralRetrospectiveCorrectionEnabled, and the watch handles both the
same way: saved with the loop's state so a relaunch keeps them, and used
to pick the strategy exactly as the phone does. Stock's
GlucoseBasedApplicationFactorStrategy is now compiled by the watch.

The field is optional, so no protocol version change: an older phone
sends nothing and the watch keeps the constant factor.

Tests: the factor follows the grant's flag; a grant without it keeps
the constant; a resume restores it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mid-loan checkpoints reconcile the pod's delivered total against the
loan's records and, inside the band, move the audit window's start to
that reading. The records count a bolus whole at its start, but the pod
delivers it over a few seconds. A reading taken during a bolus showed
less than the records said; the shortfall was inside the band, so the
checkpoint was accepted and the window moved past a bolus that had not
been delivered. The hand-back verdict then found it unexplained and
opened the loop (bench 2026-10-04: a 0.15 U automatic bolus, residual
+0.25 U, automatic dosing stopped until the loop was toggled).

A reading taken while any recorded bolus is still delivering is now not
a checkpoint: the window stays open and the next reading closes it.
Boluses keep counting whole, so nothing is split or prorated.

Test: a reading taken mid-bolus is no checkpoint, and the hand-back
stays closed (fails without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch app

Two more pieces of LoopDataManager.swift the watch carried copies of:

- PumpManagerStatus.BasalDeliveryState's currentTempBasal and
  currentBasalRate move unchanged into LoopAlgorithmSupport.swift. The
  watch's runningTempBasal() copy is deleted; its three callers read
  basalDeliveryState?.currentTempBasal.
- loop()'s four checks on its input (no glucose, glucose too old,
  glucose in the future, pump data too old) become
  StoredDataAlgorithmInput.checkRecency(at:lastAddedPumpData:), called
  by both loop()s in place of the inline checks and the watch's copy.

The watch's automatedTreatmentState stays its own: only MockKit's phone
UI reads it, and nothing does on the watch. A comment says so.

No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dead since the loop was folded into one pass, or never used:
- the verdict's .recommendationExpired case (nothing returns it);
- two do { } blocks with no catch, unwrapped;
- the diagnostics' two copies of the same effect difference, now one;
- a constant "id=—" printed into every [iob-decomp] row;
- notePhoneGlucoseDelivered(), a one-line wrapper.

Comments that no longer described the code:
- WatchSettingsProvider credited WatchLoopManager.fetchData with work
  the shared StoredDataAlgorithmInput.fetch now does;
- LoopAppManager said the watch raises missed-meal notifications (it
  does not; detection pauses during a loan);
- GlucoseAlertManager+Watch referred to a "listener above" that is in
  stock's file, not this one;
- lastRecommendation's doc said a failed cycle shows none; that holds
  for a failed compute only.

The close-the-loop call on the glance now says why it reads the pod
before computing, unlike stock: the watch has no pump heartbeat, and a
temp set on a stale view of the pod can fault it.

GlucoseAlertManager.episodeStateKey loses `private` so the watch's
episode reset uses it instead of a copy of the string.

Test: an open-loop cycle with the pod held stores its decision, sends
nothing, and leaves lastLoopCompleted alone.

No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five multi-line hooks in stock files become one line each; the logic
and its explanation move into the Sport Mode extension files:

- WatchDataManager.session(_:didReceiveMessage:) → podLoanHandlesMessage
- ExtensionDelegate.session(_:didReceiveUserInfo:) → podLoanRoutesUserInfo
- watch LoopDataManager.updateContext → podLoanAbsorbsPhoneContext
- CarbAndBolusFlowViewModel.recommendBolus and sendSetBolusUserInfo →
  loanSessionIfActive plus the existing loan paths

A comment added to unchanged stock code in CarbList is removed.

project.pbxproj: 84 lines where Xcode had only rewritten the comment
text (e.g. "Loop/Resources/Sounds/crying.caf" → "crying.caf") are
restored to stock's text, so the file's diff shows membership changes
only.

No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e after a relaunch

From the grant until the records land, the phone leaves the glucose
alarms to the watch. Three windows inside that span had none:

- During takeover and a resume rebuild, relayed readings were dropped
  (ingestPhoneGlucose required a held pod) or written only to the
  stock chart store. They are now stored in the loop's store and
  evaluated whenever the wrist owns the alarms: taking over, live,
  handing back, draining or resuming. A cycle still runs only once a
  pod is held.
- A resume that failed into a recovered drain returned before
  configuring the wrist's alerts. They are now configured first.

After a relaunch the first cycle could wait up to five minutes for the
next reading; a resume now runs one at once, as stock does at launch.
That replaces the "reading arrived mid-rebuild" flags, which are
removed.

Tests: a relayed low alarms before the pod is held; a failed resume
leaves the alarms on the wrist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ore the loop gets the pump; clear the pod's alerts at the end

Hand-back set the phase to handing back, and took the pump from the
loop, on the loan queue with no ordering against the loop's queue. A
cycle already sending a dose would still send it, and the pump's report
of it would arrive after the phase changed, when the journal no longer
records doses: a dose the hand-back's records do not contain. The pump
is now taken from the loop on the loop's own queue, behind any cycle in
flight, and the hand-back continues on the loan queue only after that,
so the dose is journaled while the loan is still live and rides in the
final offer. A second finalize trigger during the wait is ignored. No
synchronous wait, because the pump manager reports to the loan queue.

At takeover the loop got the pump before the insulin the phone's copy
cannot explain was booked; a reading in between could run a cycle that
did not see it. The loop now gets the pump after the booking.

At the end of a loan the wrist withdraws the pod's alerts still
standing, and an OK on a pod alert after the loan has ended withdraws
it, so a repeating pod alert no longer goes on sounding on the wrist
while the phone raises the same one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The algorithm reads glucose back as far as the carb window (12 h,
LoopConstants.maxCarbEntryPastTime). The grant seeded 3 h, so a loan
without a watch G7 started with no observed absorption for carbs 3 to
12 h old, and the watch's COB ran above the phone's: the over-insulin
direction. The grant now seeds the whole window.

Cost: about 25 KB more in the grant (145 readings with UUID-length
identifiers), which puts a typical grant near 50 KB of the 60 KB urgent
limit. Every grant also goes on the queued channel, so a grant over the
urgent limit would slow a takeover, not stop it.

Test: 12 h of glucose leaves the grant inside the urgent limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…like

Bench, 2026-10-04: a new sensor took 4 min 51 s from the watch's first scan
to discovery, then the system Bluetooth pairing prompt. The alert now names
Loop on the watch (the phone app had just run the sensor's pairing) and
says to wait for the pairing request rather than "until it connects".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone's sensor settings ride in every context. The same sensor in a
later context keeps the watch's manager and its link; a new sensor builds a
new one that has never connected, so it searches until the sensor is found,
and the old manager lets go first. Sabotage-checked both ways: never
rebuilding and always rebuilding each fail it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A phone context arrives after every phone loop cycle, loan or not; when it
carries the same sensor the watch now asks its CGM to re-check acquisition
(G7SensorKit's recheckAcquisition, through WatchDeviceManagers, the one
file that names device kits). A lost Bluetooth callback that left no
connect standing is then corrected at the next phone cycle instead of at
the next app launch (2026-10-05: 79 minutes without a direct reading).

Test: two same-sensor contexts reach the Bluetooth manager's re-check
twice (sabotage-checked: without the hook it counts 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the phone away there is no phone context to wake the re-check, so a
lost Bluetooth callback could leave no connect standing until the next
launch. Looking at the watch (on a Loop-failure alert or otherwise) is the
wake that remains, so becoming active re-checks too. Covered cases: launch
(scan), each phone context, and foreground.

The sensor-search alert's pairing sentence becomes conditional, as the
note's: the system prompt appears only the first time a watch meets a
sensor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The odometer prediction floored every stretch to whole pulses. That is exact for a temp (its first
pulse comes one full interval in) but not for the schedule, which runs on an ongoing grid the records
don't place: a stretch delivers the floor or one pulse more, so flooring under-counted by about half a
pulse per stretch and per schedule item, and the error grew with the length of the loan. The schedule
is now counted as rate x time, which is unbiased; temps stay floored (counting them continuously would
drift -0.1 U/h).

No change to the loop's dosing. The prediction feeds the hand-back audit and the seize takeover check,
which books insulin the copied records can't explain; that check now books slightly less, no longer
adding the schedule's rounding to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At a cancel the pump manager re-reports the running temp, shortened, under its own identity — which
the journal never re-mints — plus the schedule resuming as a zero-length basal dose, which it dropped
as not a wrist command. So the phone kept every cancelled temp for its full programmed window: six
cancels in one 6.4-h loan, each window's audit residual exactly the schedule the pod ran after the
cancel.

The schedule resuming is now journaled as a zero-length rate record, the cancel form the phone's
books and audit already take (stock reconciles a zero-duration temp as a cancel). expectedInsulin now
resolves overlaps on the records' own spans and clips afterwards, so a cancel just before the audit
window still ends its temp.

The watch's dosing is unchanged. On the phone, the loan's dose history now ends each cancelled temp
at the cancel and carries the scheduled basal that followed, so IOB after hand-back matches what the
pod delivered.

Tests: the cancel at the window boundary, the cancel through the real DoseStore (commit + backfill),
and the watch's journaling of the resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mid-loan odometer readings no longer move the verdict's base. Each window was judged within the band
and then retired, so drift the records could not explain was absorbed piecemeal instead of adding up.
With temps exact, the schedule counted continuously and cancels reaching the phone, the whole-loan
prediction is unbiased, so the hand-back and force-reclaim verdicts both run from the takeover
reading. Each mid-loan reading now logs the loan's running residual ("[odometer]"), diagnostic only.

Audit mechanism only; no change to dosing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When carbs are entered on the watch during a loan, the watch saves them itself, which updates carbs
on board. But the carb screen still held the entry as "pending", so its next bolus recommendation
counted those carbs a second time: once as carbs on board and again as the new entry.

The fix clears the pending entry as soon as it is sent. This is the same fix LoopKit#2556 made for the
normal path, where the watch hands the entry to the phone; the loan path never reaches that code, so
it needs its own copy. Unlike LoopKit#2556, the entry isn't restored if something fails: during a loan
there's no send to the phone to retry, and a failed carb save is reported with a failure haptic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
watchOS can hand the app the same Bluetooth wake task more than once, also in a later wake, and
completing a task twice crashes the app. The guard against it was cleared at every new wake, so a
task handed over again after that was completed a second time (crash 2026-10-07, "NSMapTable count
underflow"). Completed tasks are now kept, not just their identities, so a freed address can't be
mistaken for a new task, and the list is never cleared (capped at 256). A re-handed task is logged
instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ications-v2

# Conflicts:
#	Loop.xcodeproj/project.pbxproj
…or; Big BG; ages

A background reload is granted without spending the widget's budget only while the app holds a
Bluetooth connection, and then at most once per 300 s (not documented; seen in the watch's system
logs). The G7 reads every 300 s ± 2 s, so asking at each reading collided with that limit about half
the time and left the face a reading behind.

The publisher now asks at the first moment the watch is connected — 1.2 to 3.3 s after the sensor
link comes up, or 5 to 15 s after it during a loan, while the pod exchange runs — and at least
300.1 s after the last reload the widget built while connected. It checks 2.5 s later whether the
widget built, and retries once in the next window. With the app in front it asks only if the face
hasn't drawn the current reading, and every app open refreshes it. The widget records each timeline
it builds, and that record, not the request, decides what the face has drawn.

Display:
- Big BG preset for the rectangular slot: the reading as large as the slot allows, who holds the
  pod, and the reading's age.
- Corners: the reading's age after BG and Big BG, the loop's age after Loop, in smaller text; the
  other corners use a bold label.
- BG coloured by range; a watch or phone icon shows who holds the pod.
- Ages count whole minutes from timeline entries, so they need no reload.

Tests: GlanceComplicationTests covers the refresh policy's windows, spacing, retry and in-front rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…by units

The face editor asks for a slot's shape first, so each shape is now its own widget with its own options:
the reading always, then IOB, COB and age in the combinations that fit without shrinking the font, and an
active override where a line has room. Units and the reading's arrow label the values (112↗, 3m, 1.2U, 10g,
+0.40U/h, →140); there are no titles. Slots drawn in capitals space the grams, so 4g never reads as 4G.
Circular complications put what the circle leaves out in the bezel label, where the face has one. The
rectangle offers Big BG, balanced (the reading large, IOB and COB beneath) or Glance (every value).

Standalone Eventual and Loop Status are gone; a saved choice from before falls back to the shape's first
option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeremybarnum jeremybarnum changed the title SportMode - a configurable "Loop Glance" complication SportMode - Loop Glance complications Oct 9, 2026
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.

2 participants