Widget: let a refresh answer for the loop, not just the reading - #1
Open
JustMaier wants to merge 13 commits into
Open
Widget: let a refresh answer for the loop, not just the reading#1JustMaier wants to merge 13 commits into
JustMaier wants to merge 13 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The widget's refresh button could not report a loop failure or a loop recovery
that happened between two CGM readings.
RefreshWidgetIntentreturned early whenever Nightscout had no newer glucosereading 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 themetrics: 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.
loopUpdatedAtis nil on a snapshot written before this change, and anunrankable 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
updatedAtkeeps exactly themeaning 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
updatedAtfrom thesnapshot 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:
isStalestill derives fromsnapshotAgealone, the clock-ahead handling is untouched, the stale demotionsand the orange age with its triangle are untouched, and the button keeps its four
second emphasis, its thirty second settled wording and its
arrow.clockwiseidleglyph.
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 andread 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.