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
40 changes: 34 additions & 6 deletions LoopFollow/Controllers/Nightscout/Treatments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@
import Foundation

extension MainViewController {
private struct TreatmentOccurrence: Hashable {
let id: String
let eventType: String?
let date: Date?
}

/// Nightscout duplicates can have different MongoDB `_id` values. Trio FPU siblings share an `id` but have distinct
/// times, so deduplicate by logical occurrence rather than `id`.
static func deduplicatedTreatmentEntries(_ entries: [[String: AnyObject]]) -> [[String: AnyObject]] {
var seenOccurrences = Set<TreatmentOccurrence>()
return entries.filter { entry in
guard let id = entry["id"] as? String, !id.isEmpty else { return true }
let eventType = entry["eventType"] as? String
let occurrence = TreatmentOccurrence(
id: id,
eventType: eventType,
date: treatmentOccurrenceDate(entry, eventType: eventType)
)
return seenOccurrences.insert(occurrence).inserted
}
}

private static func treatmentOccurrenceDate(_ entry: [String: AnyObject], eventType: String?) -> Date? {
let rawDate: String?
switch eventType {
case "Pump Site Change", "Site Change", "Sensor Start", "Insulin Change":
rawDate = entry["created_at"] as? String
default:
rawDate = (entry["timestamp"] as? String) ?? (entry["created_at"] as? String)
}
return rawDate.flatMap(NightscoutUtils.parseDate)
}

// NS Treatments Web Call
// Downloads Basal, Bolus, Carbs, BG Check, Notes, Overrides
func WebLoadNSTreatments() {
Expand Down Expand Up @@ -35,12 +68,7 @@ extension MainViewController {

// Process and split out treatments to individual tasks
func updateTreatments(entries: [[String: AnyObject]]) {
// Deduplicate entries by "id" field (Trio/Loop UUID)
var seenIDs = Set<String>()
let uniqueEntries = entries.filter { entry in
guard let id = entry["id"] as? String else { return true }
return seenIDs.insert(id).inserted
}
let uniqueEntries = Self.deduplicatedTreatmentEntries(entries)

var tempBasal: [[String: AnyObject]] = []
var bolus: [[String: AnyObject]] = []
Expand Down
64 changes: 64 additions & 0 deletions Tests/NightscoutTreatmentDeduplicationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// LoopFollow
// NightscoutTreatmentDeduplicationTests.swift

import Foundation
@testable import LoopFollow
import Testing

struct NightscoutTreatmentDeduplicationTests {
private typealias Entry = [String: AnyObject]

@Test("keeps Trio FPU siblings with one id and distinct times")
func keepsFPUOccurrences() {
let entries = [
entry("newer", createdAt: "2026-08-16T02:06:00Z"),
entry("older", createdAt: "2026-08-16T01:06:00Z"),
]
#expect(deduplicatedIDs(entries) == ["newer", "older"])
}

@Test("collapses duplicate Nightscout documents and keeps the first")
func collapsesDuplicates() {
let entries = [
entry("first", createdAt: "2026-08-16T01:06:00Z"),
entry("duplicate", createdAt: "2026-08-16T01:06:00Z"),
]
#expect(deduplicatedIDs(entries) == ["first"])
}

@Test("uses normalized effective time and event type")
func usesLogicalOccurrence() {
let first = entry("first", timestamp: "2026-08-16T01:06:00Z", createdAt: "2026-08-16T01:05:58Z")
let equivalent = entry("equivalent", timestamp: "2026-08-16T01:06:00.000Z", createdAt: "2026-08-16T01:06:02Z")
let createdAtOnly = entry("created-at", timestamp: nil, createdAt: "2026-08-16T01:06:00Z")
let bolus = entry("bolus", eventType: "Correction Bolus", timestamp: "2026-08-16T01:06:00Z")
#expect(deduplicatedIDs([first, equivalent, createdAtOnly, bolus]) == ["first", "bolus"])
}

@Test("preserves missing identifiers and fallback behavior")
func preservesFallbacks() {
let noID = entry("no-id", id: nil)
let blankID = entry("blank-id", id: "")
let noTime = [entry("no-time"), entry("no-time-duplicate")]
let sensor = entry("sensor", eventType: "Sensor Start", timestamp: "2026-08-16T02:00:00Z", createdAt: "2026-08-16T01:00:00Z")
let sensorDuplicate = entry("sensor-duplicate", eventType: "Sensor Start", timestamp: "2026-08-16T03:00:00Z", createdAt: "2026-08-16T01:00:00Z")
#expect(deduplicatedIDs([noID, noID, blankID, blankID]) == ["no-id", "no-id", "blank-id", "blank-id"])
#expect(deduplicatedIDs(noTime) == ["no-time"])
#expect(deduplicatedIDs([sensor, sensorDuplicate]) == ["sensor"])
}

private func deduplicatedIDs(_ entries: [Entry]) -> [String] {
MainViewController.deduplicatedTreatmentEntries(entries).compactMap { $0["_id"] as? String }
}

private func entry(_ mongoID: String, id: String? = "shared-id", eventType: String = "Carb Correction", timestamp: String? = nil, createdAt: String? = nil) -> Entry {
var result: Entry = [
"_id": mongoID as AnyObject,
"eventType": eventType as AnyObject,
]
if let id { result["id"] = id as AnyObject }
if let timestamp { result["timestamp"] = timestamp as AnyObject }
if let createdAt { result["created_at"] = createdAt as AnyObject }
return result
}
}
Loading