From 87908b05eee9461bcc14e8c64e6f1ac761a4de20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 11:41:34 +0000 Subject: [PATCH 1/9] Add CLAUDE.md with branching instructions Always branch off dev, never main. Also unignore CLAUDE.md in .gitignore so instructions persist. https://claude.ai/code/session_016VQatqaNuHQ2zupY8DtZmj --- .gitignore | 2 +- CLAUDE.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 35849edfe..d2a49db78 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,6 @@ docs/PR_configurable_slots.md docs/LiveActivityTestPlan.md # Claude -CLAUDE.md +# CLAUDE.md is intentionally tracked so instructions persist across sessions node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..55cc2c404 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# LoopFollow – Claude Instructions + +## Branching +- **Always branch off `dev`**, never off `main`. +- All pull requests should target `dev`. From fb740298c65f82103e7dda0629417afe11bca2b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 11:49:54 +0000 Subject: [PATCH 2/9] Add SessionStart hook to recreate CLAUDE.md on remote sessions CLAUDE.md is kept in .gitignore so it never appears in upstream PRs, but the hook recreates it on every fresh remote web session so project instructions (always branch off dev, never main) persist. https://claude.ai/code/session_016VQatqaNuHQ2zupY8DtZmj --- .claude/hooks/session-start.sh | 18 ++++++++++++++++++ .claude/settings.json | 14 ++++++++++++++ .gitignore | 2 +- CLAUDE.md | 5 ----- 4 files changed, 33 insertions(+), 6 deletions(-) create mode 100755 .claude/hooks/session-start.sh create mode 100644 .claude/settings.json delete mode 100644 CLAUDE.md diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..a4c245584 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -euo pipefail + +# Only run in remote (web) sessions +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +# Write CLAUDE.md with project instructions. +# CLAUDE.md is listed in .gitignore so it never pollutes upstream PRs, +# but this hook recreates it on every fresh remote session. +cat > "$CLAUDE_PROJECT_DIR/CLAUDE.md" << 'EOF' +# LoopFollow – Claude Instructions + +## Branching +- **Always branch off `dev`**, never off `main`. +- All pull requests should target `dev`. +EOF diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..e06b0338e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index d2a49db78..35849edfe 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,6 @@ docs/PR_configurable_slots.md docs/LiveActivityTestPlan.md # Claude -# CLAUDE.md is intentionally tracked so instructions persist across sessions +CLAUDE.md node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 55cc2c404..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -# LoopFollow – Claude Instructions - -## Branching -- **Always branch off `dev`**, never off `main`. -- All pull requests should target `dev`. From e912f339d43007e65024b22a7d5442024513a808 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:28:39 +0000 Subject: [PATCH 3/9] Add upstream PR workflow to CLAUDE.md instructions https://claude.ai/code/session_016VQatqaNuHQ2zupY8DtZmj --- .claude/hooks/session-start.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index a4c245584..ef023f2b3 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -15,4 +15,12 @@ cat > "$CLAUDE_PROJECT_DIR/CLAUDE.md" << 'EOF' ## Branching - **Always branch off `dev`**, never off `main`. - All pull requests should target `dev`. + +## Sending a PR upstream +When the user says "ready", execute these steps to create a clean upstream PR branch that excludes fork-only files (e.g. `.claude/`): + +1. `git fetch origin dev` — get the latest upstream dev +2. `git checkout -b pr/ origin/dev` — create a clean branch rooted at upstream's dev +3. `git cherry-pick ` — apply only the relevant fix commit(s) +4. `git push origin pr/` — push and open the PR from this branch EOF From 828f16f0eb266c28d1346103a74a8edf0e2f4df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:54:24 +0000 Subject: [PATCH 4/9] Add upstream PR standards and commit message format to CLAUDE.md https://claude.ai/code/session_016VQatqaNuHQ2zupY8DtZmj --- .claude/hooks/session-start.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index ef023f2b3..2bfcab0ee 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -23,4 +23,22 @@ When the user says "ready", execute these steps to create a clean upstream PR br 2. `git checkout -b pr/ origin/dev` — create a clean branch rooted at upstream's dev 3. `git cherry-pick ` — apply only the relevant fix commit(s) 4. `git push origin pr/` — push and open the PR from this branch + +## Upstream PR standards (loopandlearn/LoopFollow) +- **One concern per PR** — one bug fix, one feature, or one improvement. Split unrelated changes into separate PRs. +- **Branch naming** — use a descriptive name reflecting the purpose (e.g. `fix/mmol-color-display`). +- **Do not update version numbers** — only project maintainers do that. + +## Commit message format +Messages should complete the sentence: *"If applied, this commit will..."* + +- Use imperative mood: "Fix crash" not "Fixed crash" +- Capitalise the first letter, no trailing period +- Subject line: 50 characters max +- Body (if needed): wrap at 72 characters + +Good examples: +- `Fix mmol/L color comparison using display-unit precision` +- `Add alarm snooze functionality` +- `Update documentation for build process` EOF From 4b582cf68c3efafb795f9040b1b76c13f7a37b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:56:01 +0000 Subject: [PATCH 5/9] Add AI co-referencing and SwiftLint rules to CLAUDE.md --- .claude/hooks/session-start.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 2bfcab0ee..949bacb1d 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -41,4 +41,18 @@ Good examples: - `Fix mmol/L color comparison using display-unit precision` - `Add alarm snooze functionality` - `Update documentation for build process` + +## No AI co-referencing +Never mention Claude, AI, or any AI tooling in: +- Code +- Code comments +- Commit messages +- PR titles or descriptions + +All output should read as if written by a human developer. + +## SwiftLint +All code and comments must comply with the project's SwiftLint configuration. +- Do not introduce any SwiftLint violations +- If editing existing code, clean up any violations in the lines touched EOF From 0d2452a1809436ab7c6b4a31be73691b92513913 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:06:44 +0000 Subject: [PATCH 6/9] Update SwiftFormat guidance in session-start hook Corrects the CLAUDE.md instructions the hook writes on every remote session: replaces the reference to a nonexistent SwiftLint config with the actual SwiftFormat command and rules CI enforces, plus a fallback checklist for sessions without a Swift toolchain. --- .claude/hooks/session-start.sh | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 949bacb1d..448d8667d 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -51,8 +51,28 @@ Never mention Claude, AI, or any AI tooling in: All output should read as if written by a human developer. -## SwiftLint -All code and comments must comply with the project's SwiftLint configuration. -- Do not introduce any SwiftLint violations -- If editing existing code, clean up any violations in the lines touched +## SwiftFormat +This project enforces formatting via SwiftFormat 0.56.1 (pinned in `BuildTools/Package.swift`), +run in CI exactly as: + + swift run -c release --package-path BuildTools swiftformat . \ + --lint \ + --header "LoopFollow\n{file}" \ + --exclude Pods,Generated,R.generated.swift,fastlane/swift,Dependencies,dexcom-share-client-swift + +There is no `.swiftlint.yml` and no SwiftLint step anywhere in this repo — only SwiftFormat is +enforced. There is also no `.swiftformat` config file; rules are SwiftFormat's *defaults* plus +the header template above. + +Before finishing any task that touches `.swift` files: +- Run the command above (drop `--lint` to auto-fix) against the repo, or at minimum against + every file you touched, and resolve every reported violation. +- If the Swift toolchain isn't available in this session, don't guess — check for + `scripts/lint.sh` or ask the user to run the check, and manually verify against known + defaults: exact 2-line file header (`// LoopFollow` / `// {file}`) followed by a blank line, + space around all operators/commas, no trailing blank line before a closing brace, blank line + before and after `// MARK:` comments, no consecutive spaces, and consistent indentation of + wrapped literals. +- Only fix violations in files/lines you touched — don't reformat unrelated files in the same PR. +- Never mark a task done, or hand back a "ready" branch, with unresolved SwiftFormat violations. EOF From ef784636870b2cdd0d4b83c179025d9fba034858 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:16:52 +0000 Subject: [PATCH 7/9] Add SwiftFormat lint step to upstream PR checklist Corrects the stale SwiftLint reference (this repo has no SwiftLint config) and adds an explicit lint pass before pushing a cherry-picked upstream PR branch, so 'ready' branches land already lint-clean. --- .claude/hooks/session-start.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 448d8667d..77dfa9c5f 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -22,7 +22,11 @@ When the user says "ready", execute these steps to create a clean upstream PR br 1. `git fetch origin dev` — get the latest upstream dev 2. `git checkout -b pr/ origin/dev` — create a clean branch rooted at upstream's dev 3. `git cherry-pick ` — apply only the relevant fix commit(s) -4. `git push origin pr/` — push and open the PR from this branch +4. Run the SwiftFormat lint check (see `## SwiftFormat` below) against every file touched by + the cherry-picked commit(s). Fix any violations found — touching only those files/lines, + nothing else in the repo — then amend the cherry-picked commit (or add a fixup commit) so + the branch is lint-clean before it's pushed. Do not open the PR with unresolved violations. +5. `git push origin pr/` — push and open the PR from this branch ## Upstream PR standards (loopandlearn/LoopFollow) - **One concern per PR** — one bug fix, one feature, or one improvement. Split unrelated changes into separate PRs. From 3affb6c8b1db1639550fa6594857a0fb708b249e Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:15:22 -0400 Subject: [PATCH 8/9] Match TestFlight build number to GitHub run number Use GITHUB_RUN_NUMBER as the build number so TestFlight and the GitHub Actions run stay in sync. Fall back to latest TestFlight build + 1 when the run number is missing or not higher. Co-Authored-By: Claude Sonnet 5.5 --- fastlane/Fastfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8f46645c5..2dd54d4d4 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -65,7 +65,12 @@ platform :ios do api_key: api_key, ) - current_build_number = previous_build_number + 1 + # Use the GitHub Actions run number so the TestFlight build number matches + # the run shown on GitHub. Fall back to previous + 1 when the run number is + # unavailable or not higher than the latest TestFlight build (TestFlight + # rejects build numbers that do not increase). + run_number = ENV["GITHUB_RUN_NUMBER"].to_i + current_build_number = [previous_build_number + 1, run_number].max increment_build_number( xcodeproj: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", From a914fe840a35cba6bc6a3e0ce3bc1bb4234f0323 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:52:22 -0400 Subject: [PATCH 9/9] Keep last known device status for 15 minutes When IOB, COB, TDD, autosens, pump battery or uploader battery is absent from a device status response, show the last known value for up to 15 minutes before going blank, consistent with the existing not-looping threshold. Each field records when it was last received, so a field missing from a record that keeps arriving still expires. The grace period does not depend on the APS type, the pump clock or CGM timing. The Live Activity reads the same times, so it shows the last known value for 15 minutes and then nothing, like the info rows. Before it kept the last IOB, COB, TDD, autosens and battery values indefinitely. Add direct-value initialisers to InsulinMetric and CarbMetric so the display value can be rebuilt from a stored double. The pump reservoir is left to PumpReservoirResolver, and the recommended bolus is not carried over, since other screens use it. --- .../Controllers/Nightscout/DeviceStatus.swift | 10 +++++++ .../Nightscout/DeviceStatusGrace.swift | 17 ++++++++++++ .../Nightscout/DeviceStatusLoop.swift | 10 +++++++ .../Nightscout/DeviceStatusOpenAPS.swift | 25 ++++++++++++++++++ .../StorageCurrentGlucoseStateProvider.swift | 15 ++++++----- LoopFollow/Metric/CarbMetric.swift | 4 +++ LoopFollow/Metric/InsulinMetric.swift | 4 +++ LoopFollow/Storage/Storage.swift | 7 +++++ Tests/DeviceStatusGraceTests.swift | 26 +++++++++++++++++++ 9 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/DeviceStatusGrace.swift create mode 100644 Tests/DeviceStatusGraceTests.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 4cd3a65a1..6eb4f0c7b 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -146,6 +146,11 @@ extension MainViewController { { infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent) Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent + Storage.shared.lastPumpBatteryTime.value = Date().timeIntervalSince1970 + } else if let lastKnown = Observable.shared.pumpBatteryLevel.value, + DeviceStatusGrace.isFresh(Storage.shared.lastPumpBatteryTime.value) + { + infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", lastKnown) + "%") } if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject], @@ -161,6 +166,7 @@ extension MainViewController { infoManager.updateInfoData(type: .battery, value: batteryText, numericValue: upbat) Observable.shared.deviceBatteryLevel.value = upbat Observable.shared.deviceBatteryIsCharging.value = isCharging + Storage.shared.lastUploaderRecordTime.value = Date().timeIntervalSince1970 let timestamp = uploader["timestamp"] as? Date ?? Date() let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp) @@ -170,6 +176,10 @@ extension MainViewController { if deviceBatteryData.count > 30 { deviceBatteryData.removeFirst() } + } else if let lastKnown = Observable.shared.deviceBatteryLevel.value, + DeviceStatusGrace.isFresh(Storage.shared.lastUploaderRecordTime.value) + { + infoManager.updateInfoData(type: .battery, value: String(format: "%.0f", lastKnown) + "%") } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusGrace.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusGrace.swift new file mode 100644 index 000000000..dc02f5a0f --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusGrace.swift @@ -0,0 +1,17 @@ +// LoopFollow +// DeviceStatusGrace.swift + +import Foundation + +/// How long the last known value of a device status field is kept after it stops arriving. +/// The info rows and the Live Activity both use this, so they always show the same thing. +enum DeviceStatusGrace { + /// Matches the not-looping threshold. + static let period: TimeInterval = 15 * 60 + + /// Whether a value last received at `receivedAt` (seconds since 1970, 0 if never) is + /// still within the grace period. + static func isFresh(_ receivedAt: TimeInterval, now: TimeInterval = Date().timeIntervalSince1970) -> Bool { + receivedAt > 0 && now - receivedAt < period + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 423c807ff..5007d12f3 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -55,13 +55,23 @@ extension MainViewController { if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { infoManager.updateInfoData(type: .iob, value: insulinMetric) latestIOB = insulinMetric + Storage.shared.lastIOBTime.value = Date().timeIntervalSince1970 Observable.shared.iobText.value = insulinMetric.formattedValue() + } else if let lastKnown = Storage.shared.lastIOB.value, + DeviceStatusGrace.isFresh(Storage.shared.lastIOBTime.value) + { + infoManager.updateInfoData(type: .iob, value: InsulinMetric(value: lastKnown)) } // COB if let cobMetric = CarbMetric(from: lastLoopRecord["cob"], key: "cob") { infoManager.updateInfoData(type: .cob, value: cobMetric) latestCOB = cobMetric + Storage.shared.lastCOBTime.value = Date().timeIntervalSince1970 + } else if let lastKnown = Storage.shared.lastCOB.value, + DeviceStatusGrace.isFresh(Storage.shared.lastCOBTime.value) + { + infoManager.updateInfoData(type: .cob, value: CarbMetric(value: lastKnown)) } if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index f15bef344..e06c2f442 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -75,13 +75,20 @@ extension MainViewController { if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { infoManager.updateInfoData(type: .iob, value: iobMetric) latestIOB = iobMetric + Storage.shared.lastIOBTime.value = Date().timeIntervalSince1970 Observable.shared.iobText.value = iobMetric.formattedValue() + } else if let lastKnown = Storage.shared.lastIOB.value, + DeviceStatusGrace.isFresh(Storage.shared.lastIOBTime.value) + { + infoManager.updateInfoData(type: .iob, value: InsulinMetric(value: lastKnown)) } // COB + var cobSet = false if let cobMetric = CarbMetric(from: enactedOrSuggested, key: "COB") { infoManager.updateInfoData(type: .cob, value: cobMetric) latestCOB = cobMetric + cobSet = true } else if let reasonString = enactedOrSuggested["reason"] as? String { // Fallback: Extract COB from reason string let cobPattern = "COB: (\\d+(?:\\.\\d+)?)" @@ -94,6 +101,7 @@ extension MainViewController { if let fallbackCobMetric = CarbMetric(from: tempDict, key: "COB") { infoManager.updateInfoData(type: .cob, value: fallbackCobMetric) latestCOB = fallbackCobMetric + cobSet = true } else { print("Failed to create CarbMetric from extracted COB value: \(cobValue)") } @@ -104,12 +112,24 @@ extension MainViewController { print("COB pattern not found in reason string.") } } + if cobSet { + Storage.shared.lastCOBTime.value = Date().timeIntervalSince1970 + } else if let lastKnown = Storage.shared.lastCOB.value, + DeviceStatusGrace.isFresh(Storage.shared.lastCOBTime.value) + { + infoManager.updateInfoData(type: .cob, value: CarbMetric(value: lastKnown)) + } // Autosens if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" infoManager.updateInfoData(type: .autosens, value: formattedSens) Storage.shared.lastAutosens.value = sens + Storage.shared.lastAutosensTime.value = Date().timeIntervalSince1970 + } else if let lastKnown = Storage.shared.lastAutosens.value, + DeviceStatusGrace.isFresh(Storage.shared.lastAutosensTime.value) + { + infoManager.updateInfoData(type: .autosens, value: String(format: "%.0f", lastKnown * 100.0) + "%") } // Recommended Bolus @@ -167,6 +187,11 @@ extension MainViewController { { infoManager.updateInfoData(type: .tdd, value: tddMetric) Storage.shared.lastTdd.value = tddMetric.value + Storage.shared.lastTddTime.value = Date().timeIntervalSince1970 + } else if let lastKnown = Storage.shared.lastTdd.value, + DeviceStatusGrace.isFresh(Storage.shared.lastTddTime.value) + { + infoManager.updateInfoData(type: .tdd, value: InsulinMetric(value: lastKnown)) } let predBGsData: [String: AnyObject]? = { diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 76b3664a4..583211660 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -34,12 +34,15 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { // MARK: - Secondary Metrics + // The values below follow the device status grace period, like the info rows: + // the last known value is shown while it was received within the last 15 minutes. + var iob: Double? { - Storage.shared.lastIOB.value + DeviceStatusGrace.isFresh(Storage.shared.lastIOBTime.value) ? Storage.shared.lastIOB.value : nil } var cob: Double? { - Storage.shared.lastCOB.value + DeviceStatusGrace.isFresh(Storage.shared.lastCOBTime.value) ? Storage.shared.lastCOB.value : nil } // MARK: - Extended Metrics @@ -65,11 +68,11 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { } var battery: Double? { - Observable.shared.deviceBatteryLevel.value + DeviceStatusGrace.isFresh(Storage.shared.lastUploaderRecordTime.value) ? Observable.shared.deviceBatteryLevel.value : nil } var pumpBattery: Double? { - Observable.shared.pumpBatteryLevel.value + DeviceStatusGrace.isFresh(Storage.shared.lastPumpBatteryTime.value) ? Observable.shared.pumpBatteryLevel.value : nil } var basalRate: String { @@ -81,11 +84,11 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { } var autosens: Double? { - Storage.shared.lastAutosens.value + DeviceStatusGrace.isFresh(Storage.shared.lastAutosensTime.value) ? Storage.shared.lastAutosens.value : nil } var tdd: Double? { - Storage.shared.lastTdd.value + DeviceStatusGrace.isFresh(Storage.shared.lastTddTime.value) ? Storage.shared.lastTdd.value : nil } var targetLowMgdl: Double? { diff --git a/LoopFollow/Metric/CarbMetric.swift b/LoopFollow/Metric/CarbMetric.swift index c96708b44..85f3b167f 100644 --- a/LoopFollow/Metric/CarbMetric.swift +++ b/LoopFollow/Metric/CarbMetric.swift @@ -17,4 +17,8 @@ class CarbMetric: Metric { } super.init(value: value, maxFractionDigits: 0, minFractionDigits: 0) } + + init(value: Double) { + super.init(value: value, maxFractionDigits: 0, minFractionDigits: 0) + } } diff --git a/LoopFollow/Metric/InsulinMetric.swift b/LoopFollow/Metric/InsulinMetric.swift index 5c0df12fa..2f1cc3f89 100644 --- a/LoopFollow/Metric/InsulinMetric.swift +++ b/LoopFollow/Metric/InsulinMetric.swift @@ -18,6 +18,10 @@ class InsulinMetric: Metric { super.init(value: value, maxFractionDigits: 2, minFractionDigits: 0) } + init(value: Double) { + super.init(value: value, maxFractionDigits: 2, minFractionDigits: 0) + } + override func formattedValue() -> String { let decimals = abs(value) >= 10 ? 0 : 1 return Localizer.formatToLocalizedString(value, maxFractionDigits: decimals, minFractionDigits: 0) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dd63f6dd6..e2a3350f9 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -100,6 +100,13 @@ class Storage { var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) var pumpReservoirCache = StorageValue(key: "pumpReservoirCache", defaultValue: nil) + // When each device status field was last received, for the 15 minute grace period + var lastIOBTime = StorageValue(key: "lastIOBTime", defaultValue: 0) + var lastCOBTime = StorageValue(key: "lastCOBTime", defaultValue: 0) + var lastTddTime = StorageValue(key: "lastTddTime", defaultValue: 0) + var lastAutosensTime = StorageValue(key: "lastAutosensTime", defaultValue: 0) + var lastPumpBatteryTime = StorageValue(key: "lastPumpBatteryTime", defaultValue: 0) + var lastUploaderRecordTime = StorageValue(key: "lastUploaderRecordTime", defaultValue: 0) var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) diff --git a/Tests/DeviceStatusGraceTests.swift b/Tests/DeviceStatusGraceTests.swift new file mode 100644 index 000000000..fd0cb51d3 --- /dev/null +++ b/Tests/DeviceStatusGraceTests.swift @@ -0,0 +1,26 @@ +// LoopFollow +// DeviceStatusGraceTests.swift + +@testable import LoopFollow +import Testing + +struct DeviceStatusGraceTests { + private let now: Double = 1_000_000 + + @Test("a value that was never received is not fresh") + func neverReceived() { + #expect(!DeviceStatusGrace.isFresh(0, now: now)) + } + + @Test("a value received less than 15 minutes ago is fresh") + func withinGracePeriod() { + #expect(DeviceStatusGrace.isFresh(now, now: now)) + #expect(DeviceStatusGrace.isFresh(now - 14 * 60, now: now)) + } + + @Test("a value received 15 minutes ago or more is not fresh") + func afterGracePeriod() { + #expect(!DeviceStatusGrace.isFresh(now - 15 * 60, now: now)) + #expect(!DeviceStatusGrace.isFresh(now - 60 * 60, now: now)) + } +}