diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index b628737b4..14e9e391b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -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() + 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() { @@ -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() - 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]] = [] diff --git a/Tests/NightscoutTreatmentDeduplicationTests.swift b/Tests/NightscoutTreatmentDeduplicationTests.swift new file mode 100644 index 000000000..4c7696bda --- /dev/null +++ b/Tests/NightscoutTreatmentDeduplicationTests.swift @@ -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 + } +}