Skip to content

Persist glucose alert episode state across launches - #2517

Open
ps2 wants to merge 1 commit into
next-devfrom
fix/persist-glucose-alert-state
Open

ps2 wants to merge 1 commit into
next-devfrom
fix/persist-glucose-alert-state

Conversation

@ps2

@ps2 ps2 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

Acknowledge a High Glucose alert, restart the app, and the same alert fires again.

GlucoseAlertManager persists profiles, active profile, override and sounds, but not its hysteresis state:

private var lowState = AlertState()        // inBoundary, lastFiredAt, boundaryEnteredAt
private var urgentLowState = AlertState()
private var highState = AlertState()
private var predictedLowInEpisode = false

decideAndUpdate fires whenever state.lastFiredAt == nil, so a relaunch makes an ongoing episode look new:

  1. BG crosses the high threshold — alert fires, lastFiredAt set
  2. User acknowledges — AlertManager records it; GlucoseAlertManager never learns, it doesn't observe acknowledgement
  3. Relaunch — highState is back to AlertState()
  4. Next reading still above threshold — lastFiredAt == nil, so it fires again

This is a fresh issuance, not AlertStore playback: these alerts are .immediate and acknowledged, so both playback queries exclude them.

Two consequences beyond the reported symptom:

  • Urgent Low re-fires the same way, and that one is .critical.
  • A relaunch defeats an active snooze, since the repeat interval is enforced purely through lastFiredAt.

With the high-delay option enabled the re-alert arrives one delay period after launch rather than on the first reading, which is probably why it looks intermittent.

Fix

AlertState becomes Codable and the four values are stored as one EpisodeState blob in UserDefaults. A didSet on each property persists — that covers every mutation site without touching callers, since decideAndUpdate and takeRetractID take them inout and the writeback fires the observer. init restores if present.

State is persisted as-is with no aging. If the app is closed across a full high → normal → high cycle, the restored lastFiredAt suppresses the new episode until the repeat interval elapses; recovery detection handles the ordinary case, since the first in-range reading after launch resets the state and retracts.

Testing

Three tests added to GlucoseAlertManagerTests, simulating a restart by constructing a second manager over the same injected UserDefaults:

  • testHighAlertDoesNotRepeatAfterRelaunch — the reported bug
  • testRecoveryAfterRelaunchRetractsHighinBoundary survives, not just lastFiredAt
  • testHighAlertsAgainInNewEpisodeAfterRelaunch — a genuine new episode still alerts

7 tests pass. Removing the restore from init makes the suite fail, so these do catch the bug.

lowState, urgentLowState, highState and predictedLowInEpisode were in-memory
only, so every launch reset lastFiredAt to nil. Acknowledge a High Glucose
alert, restart the app, and the next in-boundary reading re-alerts it. The
same applied to Urgent Low, and a restart also defeated an active snooze,
since the repeat interval is enforced purely through lastFiredAt.

Encode the four as a single blob in UserDefaults, written from a didSet on
each and restored in init.
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