diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..77dfa9c5f --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,82 @@ +#!/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`. + +## 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. 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. +- **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` + +## 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. + +## 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 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/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)) + } +} 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",