Skip to content

Widget: let a refresh answer for the loop, not just the reading - #1

Open
JustMaier wants to merge 13 commits into
alphafrom
fix/widget-refresh-loop-status
Open

Widget: let a refresh answer for the loop, not just the reading#1
JustMaier wants to merge 13 commits into
alphafrom
fix/widget-refresh-loop-status

Conversation

@JustMaier

Copy link
Copy Markdown
Collaborator

Summary

The widget's refresh button could not report a loop failure or a loop recovery
that happened between two CGM readings.

RefreshWidgetIntent returned early whenever Nightscout had no newer glucose
reading than the stored snapshot, and returning early meant it wrote no snapshot
at all. But the snapshot carries devicestatus state as well as the reading: IOB,
COB, the projection, the recommended bolus, pump battery and reservoir, and the
not-looping verdict. Those turn over on the loop's five minute cycle and on the
fifteen minute not-looping rule, neither of which waits for the CGM. Observed
live: a healthy pump clock was restored, refresh was tapped, the widget answered
"Up to date" and went on showing "Not Looping" until the next reading arrived.

The intent now also writes when the loop has moved. Two things count as moved.
The site can be serving a devicestatus record later than the one the stored
snapshot was built from, which is a loop that has reported since. Or the record
can be the same one and have aged past fifteen minutes, which is a loop that has
stopped; a stopped loop posts nothing to announce itself, so the verdict is the
only thing that changes and no amount of waiting produces a newer record.

The comparison is made on the loop's own pump clock, now carried on the snapshot
as loopUpdatedAt, and on that verdict. It is deliberately not made on the
metrics: the app and the widget's own extension-safe fetcher read several of the
same numbers out of different devicestatus keys and through different unit
conversions, so a metric that differs between them is not evidence that anything
changed. Diffing them would have rebuilt the snapshot on nearly every tap and
thrown away the app's fuller version each time.

loopUpdatedAt is nil on a snapshot written before this change, and an
unrankable snapshot is left alone, so the old value is never mistaken for a loop
that has not moved. It fills in on the app's next write.

Background

The hazard in this widget has always been mixed-age data, and the guard against
it is that one age label governs the whole face. So updatedAt keeps exactly the
meaning it had: the time of the glucose reading that the number, the delta and
the trend describe. It is never stamped forward because devicestatus moved.

A loop only refresh copies the glucose, delta, trend and updatedAt from the
snapshot it replaces rather than rebuilding them, so the invariant holds by
construction: the age line goes on counting from the same reading it counted from
before the tap, and there is no path by which a reading can be made to look
fresher than it is.

What the age label covers, once devicestatus can advance on its own, is worth
stating plainly. It is the reading's age, and for everything else on screen it is
a ceiling rather than an exact figure. After a loop only refresh the metrics are
newer than the label says, never older, so the label overstates their age, which
is the safe direction. The one place the label can understate is the pre-existing
one: a devicestatus record up to fifteen minutes old alongside a reading that is
newer than it. That window is unchanged here, it is the same window the fifteen
minute staleness rule already defines, and this change neither widens it nor
opens a new one. A second age line was considered and rejected on those grounds:
there is no case where the fields would need one that the existing rule does not
already bound.

The fifteen minute rule itself is untouched. A record with a pump clock older
than that still has its metrics dropped rather than carried, on both paths. So
are the fields the intent cannot source: basal, override, carbs today, the
sensor, cannula and insulin ages and the profile name are still written empty on
a loop only refresh, exactly as on a full one, rather than being left to outlive
the record they came from.

The confirmation wording splits, because "Up to date" is now wrong for a tap that
did change the loop state on screen. A loop only refresh says "Loop status
updated". It stays in the grey, secondary treatment and keeps the plain checkmark
rather than the green one, since the green belongs to the reading and the reading
did not move. When the reading is stale it still says "No newer reading", which
is the fact worth the line at that point; the loop half of the answer is visible
right beside it in the warning appearing or going.

Not regressed, and checked by reading: isStale still derives from
snapshotAge alone, the clock-ahead handling is untouched, the stale demotions
and the orange age with its triangle are untouched, and the button keeps its four
second emphasis, its thirty second settled wording and its arrow.clockwise idle
glyph.

One implementation note for review. The marker recording whether the last tap
moved only the loop is kept next to the intent rather than beside the two
existing refresh markers in LAAppGroupSettings. It is written by the intent and
read by the timeline provider, both inside the extension, so it never crosses
into the app. Happy to fold it in with the others if that reads better.

Verification

Builds clean for the simulator. On-device and simulator verification is still
outstanding: the simulator on the build machine is in use by another change at
the moment, so this has not yet been exercised against a live site with the
button actually tapped. The paths that need eyes on them are a loop recovery
between readings, a loop failure with no newer record, and a repeat tap
confirming it settles rather than rewriting the snapshot each time.

JustMaier and others added 13 commits July 12, 2026 12:15
The repo has two copies of RestartLiveActivityIntent.swift; only the
root-level one is referenced by the Xcode project. The previous commit
patched the orphaned copy under LoopFollow/LiveActivity/, so the intent
never shipped. Apply the patch to the compiled file and restore the
orphan to its upstream state.
Adds a toggle to the meal screen that asks Trio to calculate the bolus
with its own bolus calculator instead of sending a manual amount.
Enabling it hides and clears the manual bolus field, shows a caption
explaining Trio computes the dose from its current glucose, IOB, and COB
under its own safety limits, requires authentication like a manual bolus,
and sends the new use_recommended_bolus payload flag. Mutually exclusive
with a manual bolus amount.

Because Trio only auto-boluses a meal timed for now, the toggle is
disabled and cleared while the meal is scheduled for later.
When Trio's Remote Meal Bolus setting is Require Review, Trio sends the
recommended amount back instead of dosing. Handle that response by opening
the bolus screen pre-filled for the caregiver to confirm through the
normal Face ID and guardrail path, so nothing is dosed from the
notification itself. A TRIO_RECOMMENDED_BOLUS category with a Review
action drives the pre-filled sheet, the recommendation is gated on its age
(fresh under 5 min, a warning to 12 min, expired after), and the delivered
notification is cleared once acted on or expired so it can't be confirmed
twice.
The per-metric formatters and the slot value lookup were private to the
Live Activity view file, so neither could be reused by another surface.
Move both alongside the rest of the shared Live Activity types and list
them into the extension target as well, so any surface can render the
same strings from the published snapshot.

The formatter move is verbatim; only the access level changed. One fix
rides along: the timestamp formatter pinned a literal "HH:mm" dateFormat,
which overrides locale conventions and forced a 24 hour clock whatever the
device was set to. Using a short time style follows the 24-Hour Time
setting instead.

The snapshot store's save now takes an optional completion, so a caller
that has to act on the written file can wait for it.
A medium widget showing a glucose chart with four configurable metric
slots, alongside the existing Live Activity. The span of history is
selectable in Edit Widget from one to twenty four hours, defaulting to
three.

The widget reads a chart series and the snapshot from the App Group, both
published by the Nightscout BG path so a user who has never enabled the
Live Activity is still served. When that cache is more than fifteen
minutes old it fetches recent entries itself, so it keeps working while
the app is not running.

Staleness is judged only by the age of the snapshot the numbers come from,
never by the chart, because the fallback refreshes the chart alone. Past
fifteen minutes the reading is demoted, the trend arrow is dropped and the
age is shown, and the metric slots demote with it. The timeline carries
entries out to four hours so the displayed age stays honest even when no
reload is granted.

Thresholds published to the App Group now come from the effective time in
range mode rather than the raw low and high lines, so the widget and the
Live Activity colour glucose the same way the app's own graph does.

The widget links Swift Charts by its SDK path: the CocoaPods framework of
the same name is generated into the shared products directory, which the
linker searches first, and it would otherwise be picked up instead.
The chart now fills the whole widget instead of a band across the top, with
the reading and the metric row floating over it. Legibility comes from holding
the plot back underneath the text rather than laying a panel over it: two soft
fields, one over the reading and one along the base, combined into a single
mask whose ramps all reach zero inside the widget, so there is no edge or
isoline anywhere. The widget background shows through where the plot is held
back, which leaves the tinted and clear appearances free to substitute their
own.

The absolute clock is replaced by how long ago the reading was taken, using a
date styled Text that the system advances on screen without spending a timeline
reload. The age therefore stays true through exactly the stretches where
WidgetKit will not refresh us and an old number is most dangerous. The offset
style rounds down to a single unit and signs its output, so a reading
timestamped in the future by a skewed clock shows as a minus rather than
passing for current.

Stale no longer states the age twice. The one age line turns orange and takes
the warning symbol, alongside the greyed number and the dropped trend arrow.
The last entry marker is gone with it: self-updating text keeps counting past
the timeline horizon, so the age is never a frozen floor that needs marking.
… style

The metric row overlays the base of the full bleed chart, so the low
threshold rule was drawn underneath it and could not be seen. The plot
now maps its content into the height between a reserved band at the base
and a strip at the top, while the scale still spans the whole view, so
the chart keeps bleeding to every edge and both rules stay readable
wherever the readings happen to sit.

The age drops the signed offset style for the unsigned relative one. The
signed style was there to expose a reading stamped in the future by a
skewed uploader clock, so that case is now caught in the entry and says
"clock ahead" instead of counting up from a future date, and demotes the
reading the way any unknown age does.

Edit Widget gains a Line Style parameter. Dots stays the default. The
line is split into one run per threshold band so the low and high colour
signal survives, and cut wherever the sensor stopped reporting for more
than twenty minutes rather than curving through the gap. Monotone
interpolation, since a spline that overshoots would draw a low that
never happened.
The widget could only ever be as current as the last time WidgetKit chose to
reload it, which is exactly when an old reading is most dangerous. The base
gains a button in its fourth place, so the three configurable blocks stay and
the fourth, which shipped empty, becomes the control. Bottom right, where a
thumb reaches without crossing the reading.

The interesting part is what the button is allowed to change. The reading, the
trend, the delta and the chart come from entries; the blocks beside them come
from devicestatus; and the widget prints one age over all of it. Refreshing only
the entries would put a fresh timestamp over an hour old IOB, so the extension
now reads devicestatus too, in both the Loop and the OpenAPS shape, and rebuilds
the whole snapshot from the pair. A record the loop wrote too long ago is
dropped rather than carried, since it no longer describes the moment the age
line claims.

What has no source in either response is written empty and reads as unavailable.
Basal, override, carbs today, the sensor, cannula and insulin ages and the
profile name are built by the app out of treatments and the profile, which is
more requests than a tap can wait for. Losing a block to its no value glyph
until the app next writes is honest; restating yesterday's number under today's
timestamp is not.

A tap that cannot reach Nightscout writes nothing at all and turns the glyph to
a warning, which later timeline entries age out of on their own. A site the
widget has no url for gets no button.
A pump reservoir the widget and the Live Activity had no value for printed
"50+U", which is a reading, and a plausible one. It came from Omnipod, which
does not put a number on the reservoir until it drops below fifty and posts a
pump record with the field simply missing. The app read that absence correctly
and stored nothing, and everything downstream then read the nothing as the
Omnipod case, whatever had actually happened.

The widget's own refresh made it reachable in earnest: a site whose devicestatus
carries no pump block at all now rebuilds the snapshot with an empty reservoir,
and the block beside the glucose would state fifty units or more for a pump it
had never heard from.

So the snapshot carries the pump's silence as its own fact. A record with no
reservoir field still means over fifty and still says so; anything else has no
reservoir to report and takes the no value glyph the other blocks use. Both
parsers set it the same way, from the same absence, and a snapshot written
before this reads as unknown, which is what it was.
The button was silent about every tap that worked. Pressing it because the
reading is four minutes old is the ordinary case, and four minutes is not long
enough for the site to have anything newer, so the widget fetched, found the
same reading, and redrew itself identically. Nothing about that is
distinguishable from a button that does nothing.

Nothing can be shown while the fetch runs. A widget does not redraw until its
intent returns, and iOS gives the button no in progress treatment of its own,
which was checked against the real widget over a fetch held open for two
seconds: no dimming, no spinner, no change at all. So the tap is answered
afterwards, in the render the intent's own reload produces.

Two successful outcomes, kept apart, because they are different answers. A
refresh that brought a newer reading resets the age line by itself and only
needs acknowledging. A refresh that found nothing newer changes nothing on
screen, and that is the one worth a sentence: the data is current, which is
information rather than consolation.

It says so beneath the age and never over it. The age line keeps counting from
the reading, and where that reading is stale it keeps its warning triangle and
its colour and the confirmation drops to "No newer reading" in grey, since a
tick next to a twenty minute old number must not read as reassurance about the
number. A stopped loop outranks both and takes the line.

The wording is fixed and short, and does not tick. An earlier version counted up
from the check, which grew a line of text across the chart that was at its
widest exactly when it mattered least. It lasts half a minute, which the
timeline pays for with one extra entry at the moment it expires, and the button
keeps its own glyph throughout: a control that turns into a tick has stopped
looking like something that can be pressed again.
The button kept its arrow whatever happened, so a tap read as a dead press
until someone noticed the wording under the age. It now changes glyph for
four seconds on landing, a checkmark for a refresh that reported and an
exclamation for one that did not, then returns to the arrow. The circle and
its border are untouched, which is what keeps it looking pressable while it
is doing that, and the wording still outlasts it by design.

The provider puts an entry at the end of the four seconds as well as at the
end of the thirty the wording gets. Those are what redraw the widget, so an
interval without one at its end does not end; two deadlines seconds apart in
a run spaced minutes apart is also what guarantees each state its time on
screen rather than leaving it to whenever the next reload lands.

A stopped loop no longer takes the whole line to itself. It led, and the
answer to the tap was dropped, which left the one case where the button gets
pressed hardest teaching nothing at all. The two now share the line, with the
loop warning still first and the refresh half using its own glyph so the
warning triangle keeps meaning what it meant.

There is still no in flight state. Writing a marker before the fetch and
asking for a reload alongside it does not give one: with the intent held open
twelve seconds the provider was not asked for a timeline once in that window,
and was asked 138ms after perform returned.
The refresh intent returned early whenever Nightscout had no newer glucose
reading, and returning early meant it wrote no snapshot at all. The snapshot
also carries everything devicestatus supplies, so IOB, COB, the projection,
the recommended bolus, the pump battery and reservoir, and the not-looping
verdict all sat frozen until a new CGM reading happened to arrive. A loop that
failed or recovered between readings could not be surfaced by tapping refresh,
which is the moment the button is most likely to be pressed.

The intent now writes on the loop's account as well. Whether the loop moved is
judged on the pump clock the record was posted with, newly carried on the
snapshot as loopUpdatedAt, and on the fifteen minute not-looping verdict, which
turns over on its own once a loop stops reporting and nothing newer will ever
arrive to say so. It is never judged on the metrics themselves: the app and the
widget's own fetcher read several of the same numbers out of different keys and
through different conversions, so a value that differs between them is not
evidence of anything.

updatedAt keeps its one meaning, the time of the reading the number, delta and
trend describe. A loop only refresh copies all four from the snapshot it
replaces, so the age line goes on counting from the same reading it counted
from before the tap, and a reading cannot be made to look fresh by a loop that
moved behind it. The fifteen minute rule that drops the metrics off an old
devicestatus record is unchanged, and the fields the intent cannot source are
still written empty rather than carried forward.

The wording splits to match. A refresh that moved only the loop says "Loop
status updated" instead of "Up to date", in grey and with the plain checkmark
rather than the green one, since the reading did not move. A stale reading
still gets "No newer reading" either way.
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