From c019abfc132f89b71d7d2418bc93ab941c036b88 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:46:10 -0400 Subject: [PATCH] Fix missing Trio FPU entries LoopFollow was deduplicating Nightscout treatments by Trio's shared FPU ID, which hid the first scheduled entry from the graph. Deduplicate by event type and occurrence time as well, preserving distinct FPU entries while still collapsing true duplicates. --- .../Controllers/Nightscout/Treatments.swift | 40 ++++++++++-- ...ightscoutTreatmentDeduplicationTests.swift | 64 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 Tests/NightscoutTreatmentDeduplicationTests.swift 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 + } +}