Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
@@ -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/<feature-name> origin/dev` — create a clean branch rooted at upstream's dev
3. `git cherry-pick <commit-sha>` — 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/<feature-name>` — 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
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
}
]
}
]
}
}
10 changes: 10 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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)
Expand All @@ -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) + "%")
}
}

Expand Down
17 changes: 17 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusGrace.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
10 changes: 10 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
25 changes: 25 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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+)?)"
Expand All @@ -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)")
}
Expand All @@ -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
Expand Down Expand Up @@ -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]? = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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? {
Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/Metric/CarbMetric.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions LoopFollow/Metric/InsulinMetric.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions LoopFollow/Storage/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ class Storage {
var lastBasal = StorageValue<String>(key: "lastBasal", defaultValue: "")
var lastPumpReservoirU = StorageValue<Double?>(key: "lastPumpReservoirU", defaultValue: nil)
var pumpReservoirCache = StorageValue<PumpReservoirCache?>(key: "pumpReservoirCache", defaultValue: nil)
// When each device status field was last received, for the 15 minute grace period
var lastIOBTime = StorageValue<TimeInterval>(key: "lastIOBTime", defaultValue: 0)
var lastCOBTime = StorageValue<TimeInterval>(key: "lastCOBTime", defaultValue: 0)
var lastTddTime = StorageValue<TimeInterval>(key: "lastTddTime", defaultValue: 0)
var lastAutosensTime = StorageValue<TimeInterval>(key: "lastAutosensTime", defaultValue: 0)
var lastPumpBatteryTime = StorageValue<TimeInterval>(key: "lastPumpBatteryTime", defaultValue: 0)
var lastUploaderRecordTime = StorageValue<TimeInterval>(key: "lastUploaderRecordTime", defaultValue: 0)
var lastAutosens = StorageValue<Double?>(key: "lastAutosens", defaultValue: nil)
var lastTdd = StorageValue<Double?>(key: "lastTdd", defaultValue: nil)
var lastTargetLowMgdl = StorageValue<Double?>(key: "lastTargetLowMgdl", defaultValue: nil)
Expand Down
26 changes: 26 additions & 0 deletions Tests/DeviceStatusGraceTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
7 changes: 6 additions & 1 deletion fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down