From 8d3fab7e60c97bc157caee99314529b74d9e9dcd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:21:47 -0700 Subject: [PATCH 01/11] Add Coordinate.distance(to:) great-circle helper Plan step: distance-helper. Adds a haversine great-circle distance in meters on Coordinate (RegionKit), needed by the flight-day detector to turn consecutive fixes into a ground speed across continent-spanning gaps where GeoPolygon's planar edge math would be wrong. Pure groundwork (no behavior change) plus unit tests. --- Where/RegionKit/Sources/Coordinate.swift | 18 +++++++++++++ Where/RegionKit/Tests/CoordinateTests.swift | 28 +++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Where/RegionKit/Sources/Coordinate.swift b/Where/RegionKit/Sources/Coordinate.swift index cc2e68bd..d6f5ec88 100644 --- a/Where/RegionKit/Sources/Coordinate.swift +++ b/Where/RegionKit/Sources/Coordinate.swift @@ -11,6 +11,24 @@ public struct Coordinate: Hashable, Codable, Sendable { self.latitude = latitude self.longitude = longitude } + + /// Great-circle (haversine) distance in meters to `other`. + /// + /// Uses a spherical-Earth model (mean radius 6 371 km), which is accurate + /// to well within a percent at any distance the app cares about. Unlike + /// `GeoPolygon`'s planar edge math — fine within one region's bounding box — + /// this stays correct across continent-spanning gaps, so it's what the + /// flight-day detector uses to turn consecutive fixes into a ground speed. + public func distance(to other: Coordinate) -> Double { + let earthRadiusMeters = 6_371_000.0 + let lat1 = latitude * .pi / 180 + let lat2 = other.latitude * .pi / 180 + let deltaLat = (other.latitude - latitude) * .pi / 180 + let deltaLon = (other.longitude - longitude) * .pi / 180 + let haversine = sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) + return 2 * earthRadiusMeters * asin(min(1, sqrt(haversine))) + } } extension Collection { diff --git a/Where/RegionKit/Tests/CoordinateTests.swift b/Where/RegionKit/Tests/CoordinateTests.swift index 79de9281..7e76f580 100644 --- a/Where/RegionKit/Tests/CoordinateTests.swift +++ b/Where/RegionKit/Tests/CoordinateTests.swift @@ -10,4 +10,32 @@ struct CoordinateTests { #expect([vertex, vertex, vertex].isValidPolygonRing) #expect(Array(repeating: vertex, count: 12).isValidPolygonRing) } + + @Test func distanceToSelfIsZero() { + let point = Coordinate(latitude: 40.7128, longitude: -74.0060) + #expect(point.distance(to: point) == 0) + } + + @Test func distanceIsSymmetric() { + let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) + let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) + #expect(abs(jfk.distance(to: sfo) - sfo.distance(to: jfk)) < 0.001) + } + + /// One degree of latitude is ~111 km anywhere on the globe; a good + /// low-distance sanity check that the haversine math is in meters. + @Test func oneDegreeOfLatitudeIsAboutOneEleventhOfAThousandKm() { + let start = Coordinate(latitude: 0, longitude: 0) + let north = Coordinate(latitude: 1, longitude: 0) + #expect(abs(start.distance(to: north) - 111_195) < 500) + } + + /// The NYC→SF great-circle distance is ~4 150 km; the detector relies on + /// this continent-spanning accuracy to read a cross-country flight's speed. + @Test func transcontinentalDistanceIsAboutFourThousandKm() { + let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) + let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) + let kilometers = jfk.distance(to: sfo) / 1000 + #expect(kilometers > 4100 && kilometers < 4200) + } } From 2973768a6ef2212b0919464479e0e812e8efa9df Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:23:51 -0700 Subject: [PATCH 02/11] Feed detectors timestamped GPS samples Plan step: input-samples. Extend DataIssueInput with per-day GPS samples (gpsVisit/significantChange only, sorted by timestamp) so a speed-based detector can walk consecutive fixes; add ReportReader.samples(inYear:) and have DataIssueScanner group them by day. Existing detectors and the aggregated inputs are unchanged. Groundwork (no behavior change). --- .../DataResolution/DataIssueDetector.swift | 9 +++++++ .../DataResolution/DataIssueScanner.swift | 25 +++++++++++++++++++ Where/WhereCore/Sources/ReportReader.swift | 8 ++++++ .../Tests/DataIssueDetectorTestSupport.swift | 2 ++ 4 files changed, 44 insertions(+) diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift b/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift index bdb3cb5e..2070ed41 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift @@ -21,6 +21,13 @@ public struct DataIssueInput: Sendable { public let year: Int public let report: YearReport public let otherDayCoordinates: [Date: [Coordinate]] + /// Passive GPS fixes for the year keyed by start-of-day, each day's samples + /// sorted ascending by timestamp. Only `.gpsVisit` / `.gpsSignificantChange` + /// sources are included — manual and evidence-implied samples carry + /// user-asserted timestamps that would produce meaningless speeds — so a + /// speed-based detector (`FlightDayDetector`) can walk consecutive fixes. + /// Unlike `report` / `otherDayCoordinates` these retain per-fix timestamps. + public let daySamples: [Date: [LocationSample]] public let primaryRegions: [Region] public let attributor: RegionAttributor public let driftThresholdMeters: Double @@ -31,6 +38,7 @@ public struct DataIssueInput: Sendable { year: Int, report: YearReport, otherDayCoordinates: [Date: [Coordinate]], + daySamples: [Date: [LocationSample]], primaryRegions: [Region], attributor: RegionAttributor, driftThresholdMeters: Double, @@ -40,6 +48,7 @@ public struct DataIssueInput: Sendable { self.year = year self.report = report self.otherDayCoordinates = otherDayCoordinates + self.daySamples = daySamples self.primaryRegions = primaryRegions self.attributor = attributor self.driftThresholdMeters = driftThresholdMeters diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift index 417f231d..5ce82497 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -100,11 +100,16 @@ public actor DataIssueScanner { let otherDayCoordinates = Dictionary( uniqueKeysWithValues: otherLocations.map { ($0.date, $0.points.map(\.coordinate)) }, ) + let daySamples = try await Self.gpsSamplesByDay( + reportReader.samples(inYear: year), + calendar: calendar, + ) let dismissed = try await reportReader.dismissedIssueKeys() let input = DataIssueInput( year: year, report: report, otherDayCoordinates: otherDayCoordinates, + daySamples: daySamples, primaryRegions: primaryRegions, attributor: attributor, driftThresholdMeters: driftThresholdMeters, @@ -156,6 +161,26 @@ public actor DataIssueScanner { cache = nil } + /// Group passive GPS fixes by start-of-day (in `calendar`), each day's + /// samples sorted ascending by timestamp, for the speed-based detectors. + /// Manual and evidence-implied samples are dropped: their timestamps are + /// user-asserted, so a speed computed across them would be meaningless. + private static func gpsSamplesByDay( + _ samples: [LocationSample], + calendar: Calendar, + ) -> [Date: [LocationSample]] { + var byDay: [Date: [LocationSample]] = [:] + for sample in samples { + switch sample.source { + case .gpsVisit, .gpsSignificantChange: + byDay[calendar.startOfDay(for: sample.timestamp), default: []].append(sample) + case .manual, .evidenceImplied: + continue + } + } + return byDay.mapValues { $0.sorted { $0.timestamp < $1.timestamp } } + } + private static func sortIssues(_ issues: [any DataIssue]) -> [any DataIssue] { issues.sorted { lhs, rhs in let lhsOrder = DataIssueCategory.allCases.firstIndex(of: lhs.category) ?? 0 diff --git a/Where/WhereCore/Sources/ReportReader.swift b/Where/WhereCore/Sources/ReportReader.swift index 6bc3db05..8749a247 100644 --- a/Where/WhereCore/Sources/ReportReader.swift +++ b/Where/WhereCore/Sources/ReportReader.swift @@ -31,6 +31,14 @@ public struct ReportReader: Sendable { ) } + /// Every persisted `LocationSample` recorded during `year`, unaggregated, so + /// callers that need per-fix timestamps (the speed-based `FlightDayDetector` + /// via `DataIssueScanner`) can walk the raw stream rather than the collapsed + /// `YearReport`. Ordering is the store's; callers that need chronology sort. + public func samples(inYear year: Int) async throws -> [LocationSample] { + try await store.samples(in: aggregator.yearInterval(year: year)) + } + /// The manual-day records (backfills and authoritative overrides) the user /// asserted for `year`, so the "logged days" management screen can list, /// edit, and delete them. Unlike `yearReport`, these are the raw user diff --git a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift index e4745460..b3142d5c 100644 --- a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift +++ b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift @@ -23,6 +23,7 @@ enum DataIssueDetectorFixtures { year: Int = 2026, days: [DayPresence] = [], otherDayCoordinates: [Date: [Coordinate]] = [:], + daySamples: [Date: [LocationSample]] = [:], primaryRegions: [Region] = [.california, .newYork], driftThresholdMeters: Double = 10000, now: Date? = nil, @@ -32,6 +33,7 @@ enum DataIssueDetectorFixtures { year: year, report: YearReport(year: year, days: days, totals: totals), otherDayCoordinates: otherDayCoordinates, + daySamples: daySamples, primaryRegions: primaryRegions, attributor: .shared, driftThresholdMeters: driftThresholdMeters, From 12ffb01821b1e4e00d5a598dc2a7e26afee011eb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:39:59 -0700 Subject: [PATCH 03/11] Add FlightDayDetector for cruise-speed days Plan step: flight-detector. Flags a day whose region set was inflated by cruise-speed GPS fixes crossing untracked geography (a flight): a fix is a fly-over point when the legs on both sides clear ~300 km/h over >=80 km, and a region is spurious when all its fixes are fly-over points. Requires at least two fly-over fixes so a lone teleport glitch doesn't read as a plane, and only fires when >=1 region is removed and >=1 survives. Adds IssueResolution.correctFlightDay, DataIssueCategory.flightDay, DataIssueID.flightDay, FlightDayIssue, and registers the detector in DataIssueScanner's default list. Thresholds injectable via @_spi(Testing). --- .../Sources/DataResolution/DataIssue.swift | 15 +++ .../DataResolution/DataIssueScanner.swift | 1 + .../DataResolution/FlightDayDetector.swift | 119 ++++++++++++++++++ .../DataResolution/FlightDayIssue.swift | 51 ++++++++ 4 files changed, 186 insertions(+) create mode 100644 Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift create mode 100644 Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift diff --git a/Where/WhereCore/Sources/DataResolution/DataIssue.swift b/Where/WhereCore/Sources/DataResolution/DataIssue.swift index 85ed6cfe..9c922f1e 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssue.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssue.swift @@ -5,6 +5,7 @@ public enum DataIssueCategory: Sendable, Hashable, CaseIterable { case missingDays case borderDrift case abruptChange + case flightDay } /// Closed set of fix shapes. The UI switches on this, so a new detector that @@ -14,12 +15,24 @@ public enum IssueResolution: Sendable, Hashable { case backfill(MissingDayRange) case relabelDay(day: DayPresence, suggestedRegions: Set, approximateMeters: Double?) case markTravelDay(earlier: DayPresence, later: DayPresence, suggestedRegions: Set) + /// A day whose region set was polluted by cruise-speed GPS fixes crossing + /// untracked geography (a flight). `keepRegions` are the endpoints/dwell + /// regions to preserve; `removedRegions` are the fly-over-only regions the + /// one-tap fix drops (applied as an authoritative `overrideDay(keepRegions)`). + /// `peakSpeedKMH` is the fastest leg, for the detail view's copy. + case correctFlightDay( + day: DayPresence, + keepRegions: Set, + removedRegions: Set, + peakSpeedKMH: Double, + ) } public enum DataIssueID: Hashable, Sendable { case missingDays(start: Date) case borderDrift(date: Date) case abruptChange(earlier: Date, later: Date) + case flightDay(start: Date) /// Stable, device-independent key for persisted dismissal and `ForEach`. public var storageKey: String { @@ -30,6 +43,8 @@ public enum DataIssueID: Hashable, Sendable { "borderDrift:\(Self.dayKey(date))" case let .abruptChange(earlier, later): "abruptChange:\(Self.dayKey(earlier)):\(Self.dayKey(later))" + case let .flightDay(start): + "flightDay:\(Self.dayKey(start))" } } diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift index 5ce82497..fef11d3b 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -48,6 +48,7 @@ public actor DataIssueScanner { MissingDaysDetector(), BorderDriftDetector(), AbruptLocationChangeDetector(), + FlightDayDetector(), ], // Defaults to an already-finished stream — *not* `AsyncStream { _ in }`, // which never yields or finishes and so would park the observation task diff --git a/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift b/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift new file mode 100644 index 00000000..567f8f51 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift @@ -0,0 +1,119 @@ +import Foundation +import RegionKit + +/// Detects days that look like a flight: consecutive GPS fixes moving at +/// cruise speed across the map. The symptom is spurious region presence — on a +/// coast-to-coast flight the fly-over states collapse to `.other` (or any +/// untracked region), so the day counts for somewhere the user only passed over +/// at 35 000 feet. +/// +/// The rule, per day, over the timestamp-sorted GPS fixes in +/// `DataIssueInput.daySamples`: +/// 1. A *leg* between consecutive fixes is a "flight leg" when its ground +/// speed is at least `speedThresholdKMH` **and** it spans at least +/// `minLegDistanceKM` — the distance floor rejects a close pair whose tiny +/// time delta manufactures a huge speed (GPS jitter, a teleport glitch). +/// 2. A fix is a *fly-over* point when the legs on **both** sides are flight +/// legs — a point you were only passing through. A leg's endpoints (the +/// take-off / landing fixes) have a flight leg on just one side, so they +/// are not fly-over points and their region survives. +/// 3. A region is *spurious* when every fix attributing the day to it is a +/// fly-over point (and it has at least one) — it exists only because the +/// flight crossed it. Endpoint and dwell regions keep at least one +/// non-fly-over fix. +/// +/// It reports an issue only when at least one region would be removed and at +/// least one survives, so a day spent entirely mid-flight (no endpoint on this +/// calendar day — an overnight flight) is left alone rather than blanked. +public struct FlightDayDetector: DataIssueDetector { + public typealias Issue = FlightDayIssue + + /// Minimum ground speed for a leg to count as flight. ~300 km/h sits well + /// above sustained driving / rail and far below jet cruise (~800-900 km/h), + /// so it separates the two with a wide margin. + let speedThresholdKMH: Double + /// Minimum leg distance for a leg to count as flight, so a close pair with a + /// tiny time delta can't manufacture a flight-speed leg out of GPS jitter. + let minLegDistanceKM: Double + + public init() { + self.init(speedThresholdKMH: 300, minLegDistanceKM: 80) + } + + @_spi(Testing) + public init(speedThresholdKMH: Double, minLegDistanceKM: Double) { + self.speedThresholdKMH = speedThresholdKMH + self.minLegDistanceKM = minLegDistanceKM + } + + public func detectIssues(in input: DataIssueInput) -> [FlightDayIssue] { + var issues: [FlightDayIssue] = [] + for day in input.report.days { + guard let samples = input.daySamples[day.date] else { continue } + if let issue = flightIssue(for: day, samples: samples, attributor: input.attributor) { + issues.append(issue) + } + } + return issues + } + + private func flightIssue( + for day: DayPresence, + samples: [LocationSample], + attributor: RegionAttributor, + ) -> FlightDayIssue? { + guard samples.count >= 3 else { return nil } + + // `flightLeg[i]` is the leg from `samples[i]` to `samples[i + 1]`. + var flightLeg = [Bool](repeating: false, count: samples.count - 1) + var peakSpeedKMH = 0.0 + for index in flightLeg.indices { + let distanceMeters = samples[index].coordinate + .distance(to: samples[index + 1].coordinate) + let seconds = samples[index + 1].timestamp + .timeIntervalSince(samples[index].timestamp) + guard seconds > 0 else { continue } + let speedKMH = distanceMeters / seconds * 3.6 + if speedKMH >= speedThresholdKMH, distanceMeters >= minLegDistanceKM * 1000 { + flightLeg[index] = true + peakSpeedKMH = max(peakSpeedKMH, speedKMH) + } + } + + // A region is spurious when it has at least one fly-over fix and no + // other kind. Track both facts (plus a total fly-over count) as we walk + // the fixes. + var hasFlyOver: Set = [] + var hasGrounded: Set = [] + var flyOverCount = 0 + for index in samples.indices { + let flightBefore = index > 0 && flightLeg[index - 1] + let flightAfter = index < flightLeg.count && flightLeg[index] + let region = attributor.region(at: samples[index].coordinate) + if flightBefore, flightAfter { + hasFlyOver.insert(region) + flyOverCount += 1 + } else { + hasGrounded.insert(region) + } + } + + // A flight is a *sustained* cruise, not a lone outlier: a teleport + // glitch jumps out and straight back, leaving a single fly-over fix + // between two flight legs. Require at least two so one bad fix — which + // is a different kind of data issue — doesn't read as a plane. + guard flyOverCount >= 2 else { return nil } + + let spurious = hasFlyOver.subtracting(hasGrounded) + let removedRegions = day.regions.intersection(spurious) + let keepRegions = day.regions.subtracting(spurious) + guard !removedRegions.isEmpty, !keepRegions.isEmpty else { return nil } + + return FlightDayIssue( + day: day, + keepRegions: keepRegions, + removedRegions: removedRegions, + peakSpeedKMH: peakSpeedKMH, + ) + } +} diff --git a/Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift b/Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift new file mode 100644 index 00000000..e502c32b --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift @@ -0,0 +1,51 @@ +import Foundation +import RegionKit + +/// A single calendar day whose region set was inflated by cruise-speed GPS +/// fixes crossing untracked geography — i.e. a flight. `keepRegions` are the +/// real endpoints/dwell regions; `removedRegions` are the fly-over-only regions +/// the fix drops. `peakSpeedKMH` is the fastest leg, used for the detail view's +/// explanation copy. +public struct FlightDayIssue: DataIssue, Hashable { + public let day: DayPresence + public let keepRegions: Set + public let removedRegions: Set + public let peakSpeedKMH: Double + + public init( + day: DayPresence, + keepRegions: Set, + removedRegions: Set, + peakSpeedKMH: Double, + ) { + self.day = day + self.keepRegions = keepRegions + self.removedRegions = removedRegions + self.peakSpeedKMH = peakSpeedKMH + } + + public var id: DataIssueID { + .flightDay(start: day.date) + } + + public var category: DataIssueCategory { + .flightDay + } + + public var sortKey: Date { + day.date + } + + public var isDismissible: Bool { + true + } + + public var resolution: IssueResolution { + .correctFlightDay( + day: day, + keepRegions: keepRegions, + removedRegions: removedRegions, + peakSpeedKMH: peakSpeedKMH, + ) + } +} From ae233c7772d251d7e0c6e8a7972a373237910eca Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:40:09 -0700 Subject: [PATCH 04/11] Test FlightDayDetector Plan step: flight-detector-tests. Covers the July 14 NYC->SF profile (flags, keeps {NY, CA}, drops .other, peak >300 km/h), and confirms it ignores fast driving below threshold, a lone teleport glitch, a mid-flight-only day, and a day with no samples. Adds a gpsSample fixture helper to DataIssueDetectorTestSupport. --- .../Tests/DataIssueDetectorTestSupport.swift | 16 +++ .../Tests/FlightDayDetectorTests.swift | 110 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 Where/WhereCore/Tests/FlightDayDetectorTests.swift diff --git a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift index b3142d5c..291f94f6 100644 --- a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift +++ b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift @@ -19,6 +19,22 @@ enum DataIssueDetectorFixtures { ))!) } + /// A passive GPS fix at `coordinate`, `hoursAfterStart` into `dayStart`, for + /// building the timestamp-sorted `daySamples` the speed-based detector walks. + static func gpsSample( + dayStart: Date, + hoursAfterStart: Double, + _ coordinate: Coordinate, + source: SampleSource = .gpsSignificantChange, + ) -> LocationSample { + LocationSample( + timestamp: dayStart.addingTimeInterval(hoursAfterStart * 3600), + coordinate: coordinate, + horizontalAccuracy: 20, + source: source, + ) + } + static func input( year: Int = 2026, days: [DayPresence] = [], diff --git a/Where/WhereCore/Tests/FlightDayDetectorTests.swift b/Where/WhereCore/Tests/FlightDayDetectorTests.swift new file mode 100644 index 00000000..5eba1bad --- /dev/null +++ b/Where/WhereCore/Tests/FlightDayDetectorTests.swift @@ -0,0 +1,110 @@ +import Foundation +import RegionKit +import Testing +import WhereCore + +private typealias Fixtures = DataIssueDetectorFixtures + +struct FlightDayDetectorTests { + // Real coordinates the shared attributor resolves as expected. + private static let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) // New York + private static let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) // California + private static let illinois = Coordinate(latitude: 40.29, longitude: -90.39) // .other + private static let colorado = Coordinate(latitude: 39.53, longitude: -106.16) // .other + private static let nevada = Coordinate(latitude: 38.68, longitude: -116.90) // .other + + /// The July 14 NYC→SF profile: two grounded NY fixes in the morning, a run + /// of cruise-speed legs across untracked states, then grounded SFO fixes. + @Test func flagsCoastToCoastFlight() { + let date = Fixtures.day(2026, 7, 14) + let day = DayPresence(date: date, regions: [.newYork, .other, .california]) + let samples = [ + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 8.0, Self.jfk), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 8.5, Self.jfk), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 12.0, Self.jfk), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 13.5, Self.illinois), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 15.0, Self.colorado), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 16.5, Self.nevada), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 17.5, Self.sfo), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 18.0, Self.sfo), + ] + let issues = FlightDayDetector().detectIssues(in: Fixtures.input( + days: [day], + daySamples: [date: samples], + )) + + #expect(issues.count == 1) + #expect(issues[0].keepRegions == [.newYork, .california]) + #expect(issues[0].removedRegions == [.other]) + #expect(issues[0].peakSpeedKMH > 300) + guard case let .correctFlightDay(d, keep, removed, peak) = issues[0].resolution else { + Issue.record("expected correctFlightDay resolution") + return + } + #expect(d == day) + #expect(keep == [.newYork, .california]) + #expect(removed == [.other]) + #expect(peak > 300) + } + + /// A fast road trip that dips into `.other` but never reaches cruise speed: + /// no leg clears the threshold, so nothing is a fly-over point. + @Test func ignoresFastDrivingBelowThreshold() { + let date = Fixtures.day(2026, 7, 14) + let day = DayPresence(date: date, regions: [.newYork, .other]) + let newYorkCity = Coordinate(latitude: 40.7128, longitude: -74.0060) + let newJersey = Coordinate(latitude: 40.05, longitude: -74.60) // .other, ~80 km away + let samples = [ + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 9.0, newYorkCity), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 10.0, newJersey), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 11.0, newYorkCity), + ] + let issues = FlightDayDetector().detectIssues(in: Fixtures.input( + days: [day], + daySamples: [date: samples], + )) + #expect(issues.isEmpty) + } + + /// A single teleport glitch — one bad fix that jumps far away and straight + /// back — is a different data issue, not a plane, so it isn't flagged. + @Test func ignoresLoneTeleportGlitch() { + let date = Fixtures.day(2026, 7, 14) + let day = DayPresence(date: date, regions: [.newYork, .other]) + let samples = [ + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 9.0, Self.jfk), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 9.05, Self.colorado), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 9.1, Self.jfk), + ] + let issues = FlightDayDetector().detectIssues(in: Fixtures.input( + days: [day], + daySamples: [date: samples], + )) + #expect(issues.isEmpty) + } + + /// A day spent entirely mid-cruise (no grounded endpoint on this calendar + /// day) leaves the crossed region grounded at its first/last fix, so there + /// is nothing spurious to remove — the day is left alone, not blanked. + @Test func skipsMidFlightOnlyDay() { + let date = Fixtures.day(2026, 7, 14) + let day = DayPresence(date: date, regions: [.other]) + let samples = [ + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 0.5, Self.illinois), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 2.0, Self.colorado), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 3.5, Self.nevada), + ] + let issues = FlightDayDetector().detectIssues(in: Fixtures.input( + days: [day], + daySamples: [date: samples], + )) + #expect(issues.isEmpty) + } + + @Test func skipsDayWithNoSamples() { + let date = Fixtures.day(2026, 7, 14) + let day = DayPresence(date: date, regions: [.newYork, .other, .california]) + let issues = FlightDayDetector().detectIssues(in: Fixtures.input(days: [day])) + #expect(issues.isEmpty) + } +} From 0725401acc7bcde716ef94a6c7f95ea38c445e57 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 13:03:11 -0700 Subject: [PATCH 05/11] Extract RecordedPointsMap from RegionDaysView Plan step: extract-map. Pulls the map + pin de-duplication + GPS uncertainty circles out of RegionDaysView into a reusable RecordedPointsMap that tints pins per region (so a multi-region day like a flight reads clearly). RegionDaysView now feeds it single-region points; the flight detail view and 'Fix this day' screen reuse it next. --- .../Sources/Secondary/RegionDaysView.swift | 98 ++----------- .../Sources/Shared/RecordedPointsMap.swift | 137 ++++++++++++++++++ 2 files changed, 150 insertions(+), 85 deletions(-) create mode 100644 Where/WhereUI/Sources/Shared/RecordedPointsMap.swift diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index 0fb0d062..66c1b47d 100644 --- a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift +++ b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift @@ -1,4 +1,3 @@ -import MapKit import RegionKit import SwiftUI import WhereCore @@ -13,17 +12,12 @@ struct RegionDaysView: View { let report: YearReportModel /// Raw per-day coordinates for this region, loaded asynchronously from the - /// store. Drives the map pins and each row's representative point. Empty - /// until loaded (and in previews/tests, which seed no raw samples). - @State private var pins: [MapPin] = [] + /// store. `mapPoints` drives the map pins; `coordinatesByDay` feeds each + /// row's representative point. Empty until loaded (and in previews/tests, + /// which seed no raw samples). + @State private var mapPoints: [RecordedMapPoint] = [] @State private var coordinatesByDay: [Date: [Coordinate]] = [:] - @Environment(\.stylesheet) private var stylesheet - - private var regionMap: WhereStylesheet.RegionMapStyle { - stylesheet.regionMap - } - private var days: [DayPresence] { report.days(in: region) } @@ -49,7 +43,13 @@ struct RegionDaysView: View { locations.map { ($0.date, $0.points.map(\.coordinate)) }, uniquingKeysWith: { first, _ in first }, ) - pins = MapPin.deduplicated(from: locations.flatMap(\.points)) + mapPoints = locations.flatMap(\.points).map { + RecordedMapPoint( + coordinate: $0.coordinate, + horizontalAccuracy: $0.horizontalAccuracy, + region: region, + ) + } } @ViewBuilder @@ -62,47 +62,14 @@ struct RegionDaysView: View { } } else { VStack(spacing: 0) { - if !pins.isEmpty { - map + if !mapPoints.isEmpty { + RecordedPointsMap(points: mapPoints) } dayList } } } - private var map: some View { - Map(initialPosition: .automatic) { - ForEach(pins) { pin in - if let radius = drawnUncertaintyRadius(for: pin) { - MapCircle(center: pin.coordinate, radius: radius) - .foregroundStyle(region.style.tint - .opacity(regionMap.uncertaintyFillOpacity)) - .stroke( - region.style.tint.opacity(regionMap.uncertaintyStrokeOpacity), - lineWidth: regionMap.uncertaintyStrokeWidth, - ) - } - Marker("", coordinate: pin.coordinate) - .tint(region.style.tint) - } - } - .mapStyle(.standard(pointsOfInterest: .excludingAll)) - .frame(height: regionMap.height) - .accessibilityLabel(Strings.secondaryRegionMapAccessibility) - } - - /// Radius in meters to draw for a pin's GPS uncertainty, or `nil` when the - /// fix is precise enough that a circle would just clutter the map. The cap - /// is deliberately generous so the user sees close to the real radius (the - /// translucent fill keeps the map readable underneath); it only reins in a - /// pathologically coarse fix so it can't zoom the auto-framed map way out. - private func drawnUncertaintyRadius(for pin: MapPin) -> CLLocationDistance? { - let minimumVisible = 25.0 - let maximumDrawn = 3000.0 - guard pin.horizontalAccuracy > minimumVisible else { return nil } - return min(pin.horizontalAccuracy, maximumDrawn) - } - private var dayList: some View { List { Section { @@ -121,45 +88,6 @@ struct RegionDaysView: View { } } -/// A map annotation for one recorded point, with its GPS uncertainty radius. -/// Points are de-duplicated onto a coarse grid so a day's jitter collapses to a -/// single pin and the map isn't carpeted with overlapping markers. -private struct MapPin: Identifiable { - let id: Int - let coordinate: CLLocationCoordinate2D - let horizontalAccuracy: CLLocationDistance - - /// ~0.01° (~1 km) buckets, capped so a very dense region stays responsive. - /// When several points land in one bucket the most accurate (smallest - /// radius) wins, so the pin sits on the best fix and the drawn uncertainty - /// circle reflects it rather than a coarse outlier. - static func deduplicated(from points: [RegionDayPoint], limit: Int = 250) -> [MapPin] { - var bestByBucket: [Int: RegionDayPoint] = [:] - var bucketOrder: [Int] = [] - for point in points { - let latBucket = Int((point.coordinate.latitude * 100).rounded()) - let lngBucket = Int((point.coordinate.longitude * 100).rounded()) - let bucket = latBucket &* 100_000 &+ lngBucket - if let existing = bestByBucket[bucket] { - if point.horizontalAccuracy < existing.horizontalAccuracy { - bestByBucket[bucket] = point - } - } else { - bestByBucket[bucket] = point - bucketOrder.append(bucket) - } - } - return bucketOrder.prefix(limit).enumerated().compactMap { index, bucket in - guard let point = bestByBucket[bucket] else { return nil } - return MapPin( - id: index, - coordinate: point.coordinate.clLocationCoordinate, - horizontalAccuracy: point.horizontalAccuracy, - ) - } - } -} - /// One day in the region's list: the date, the place it reverse-geocodes to /// (when a coordinate is known), and the regions it currently counts for so /// the user can spot a wrong attribution at a glance. diff --git a/Where/WhereUI/Sources/Shared/RecordedPointsMap.swift b/Where/WhereUI/Sources/Shared/RecordedPointsMap.swift new file mode 100644 index 00000000..4a00a99d --- /dev/null +++ b/Where/WhereUI/Sources/Shared/RecordedPointsMap.swift @@ -0,0 +1,137 @@ +import MapKit +import RegionKit +import SwiftUI +import WhereCore + +/// One recorded GPS point to plot on `RecordedPointsMap`: its coordinate, the +/// originating fix's horizontal accuracy (for the uncertainty circle), and the +/// region it attributes to (which tints the pin). A day that spans several +/// regions — a flight, say — therefore draws each leg's points in that +/// region's color, so the fly-over `.other` points read distinctly from the +/// grounded endpoints. +struct RecordedMapPoint: Hashable { + let coordinate: Coordinate + let horizontalAccuracy: Double + let region: Region +} + +/// A map of recorded GPS points with per-region tinting and GPS uncertainty +/// circles. Extracted from `RegionDaysView` so the Elsewhere drill-in, the +/// flight-day detail view, and the "Fix this day" screen share one renderer. +/// Points are de-duplicated onto a coarse grid (per region) so a day's jitter +/// collapses to a single pin and the map isn't carpeted with markers. +struct RecordedPointsMap: View { + let points: [RecordedMapPoint] + + @Environment(\.stylesheet) private var stylesheet + + private var regionMap: WhereStylesheet.RegionMapStyle { + stylesheet.regionMap + } + + var body: some View { + Map(initialPosition: .automatic) { + ForEach(pins) { pin in + if let radius = drawnUncertaintyRadius(for: pin) { + MapCircle(center: pin.coordinate, radius: radius) + .foregroundStyle(pin.region.style.tint + .opacity(regionMap.uncertaintyFillOpacity)) + .stroke( + pin.region.style.tint.opacity(regionMap.uncertaintyStrokeOpacity), + lineWidth: regionMap.uncertaintyStrokeWidth, + ) + } + Marker("", coordinate: pin.coordinate) + .tint(pin.region.style.tint) + } + } + .mapStyle(.standard(pointsOfInterest: .excludingAll)) + .frame(height: regionMap.height) + .accessibilityLabel(Strings.secondaryRegionMapAccessibility) + } + + private var pins: [Pin] { + Pin.deduplicated(from: points) + } + + /// Radius in meters to draw for a pin's GPS uncertainty, or `nil` when the + /// fix is precise enough that a circle would just clutter the map. The cap + /// is deliberately generous so the user sees close to the real radius (the + /// translucent fill keeps the map readable underneath); it only reins in a + /// pathologically coarse fix so it can't zoom the auto-framed map way out. + private func drawnUncertaintyRadius(for pin: Pin) -> CLLocationDistance? { + let minimumVisible = 25.0 + let maximumDrawn = 3000.0 + guard pin.horizontalAccuracy > minimumVisible else { return nil } + return min(pin.horizontalAccuracy, maximumDrawn) + } +} + +/// A map annotation for one recorded point: its coordinate, uncertainty radius, +/// and attributed region (for tinting). Points are de-duplicated onto a coarse +/// grid *per region* so a day's jitter collapses to a single pin per area while +/// two regions that happen to share a grid cell each keep their own marker. +private struct Pin: Identifiable { + let id: Int + let coordinate: CLLocationCoordinate2D + let horizontalAccuracy: CLLocationDistance + let region: Region + + static func deduplicated(from points: [RecordedMapPoint], limit: Int = 250) -> [Pin] { + struct Bucket: Hashable { + let region: Region + let cell: Int + } + var bestByBucket: [Bucket: RecordedMapPoint] = [:] + var bucketOrder: [Bucket] = [] + for point in points { + let latBucket = Int((point.coordinate.latitude * 100).rounded()) + let lngBucket = Int((point.coordinate.longitude * 100).rounded()) + let bucket = Bucket(region: point.region, cell: latBucket &* 100_000 &+ lngBucket) + if let existing = bestByBucket[bucket] { + if point.horizontalAccuracy < existing.horizontalAccuracy { + bestByBucket[bucket] = point + } + } else { + bestByBucket[bucket] = point + bucketOrder.append(bucket) + } + } + return bucketOrder.prefix(limit).enumerated().compactMap { index, bucket in + guard let point = bestByBucket[bucket] else { return nil } + return Pin( + id: index, + coordinate: point.coordinate.clLocationCoordinate, + horizontalAccuracy: point.horizontalAccuracy, + region: point.region, + ) + } + } +} + +#if DEBUG + #Preview("Flight day") { + RecordedPointsMap(points: [ + RecordedMapPoint( + coordinate: Coordinate(latitude: 40.6413, longitude: -73.7781), + horizontalAccuracy: 30, + region: .newYork, + ), + RecordedMapPoint( + coordinate: Coordinate(latitude: 39.53, longitude: -106.16), + horizontalAccuracy: 200, + region: .other, + ), + RecordedMapPoint( + coordinate: Coordinate(latitude: 38.68, longitude: -116.90), + horizontalAccuracy: 200, + region: .other, + ), + RecordedMapPoint( + coordinate: Coordinate(latitude: 37.6213, longitude: -122.3790), + horizontalAccuracy: 30, + region: .california, + ), + ]) + } +#endif From d524ce2ee25cf80462586d8931e235bd1bb0be93 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 13:03:27 -0700 Subject: [PATCH 06/11] Add per-day points read across regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step: day-points-read. Adds DayAggregator.pointsByRegion(onDay:), ReportReader.locations(onDay:), and YearReportModel.locations(onDay:), returning a day's recorded points grouped by attributed region — so the flight detail view and 'Fix this day' screen can map every point of a multi-region day at once. Includes DayAggregator tests. --- Where/WhereCore/Sources/DayAggregator.swift | 24 ++++++++++ Where/WhereCore/Sources/ReportReader.swift | 14 ++++++ .../WhereCore/Tests/DayAggregatorTests.swift | 44 +++++++++++++++++++ .../Sources/Model/YearReportModel.swift | 14 ++++++ 4 files changed, 96 insertions(+) diff --git a/Where/WhereCore/Sources/DayAggregator.swift b/Where/WhereCore/Sources/DayAggregator.swift index 2c4ad800..559c0e52 100644 --- a/Where/WhereCore/Sources/DayAggregator.swift +++ b/Where/WhereCore/Sources/DayAggregator.swift @@ -66,6 +66,30 @@ public struct DayAggregator: Sendable { .sorted { $0.date < $1.date } } + /// Group the recorded points that fell on `day` by the region they + /// attribute to, keeping each raw point (coordinate + horizontal accuracy). + /// Unlike `locations(in:)`, which projects one region across the whole year, + /// this covers *every* region a single day touched — so the "Fix this day" + /// screen and the flight-day detail view can plot all of a day's points at + /// once (e.g. a flight's origin, fly-over, and destination in one map). + /// Like `locations(in:)` it reflects recorded points, not manual overlays. + public func pointsByRegion( + onDay day: Date, + samples: [LocationSample], + attributor: RegionAttributor, + ) -> [Region: [RegionDayPoint]] { + let dayStart = calendar.startOfDay(for: day) + var byRegion: [Region: [RegionDayPoint]] = [:] + for sample in samples where calendar.startOfDay(for: sample.timestamp) == dayStart { + let region = attributor.region(at: sample.coordinate) + byRegion[region, default: []].append(RegionDayPoint( + coordinate: sample.coordinate, + horizontalAccuracy: sample.horizontalAccuracy, + )) + } + return byRegion + } + /// One representative coordinate per region: the point inside the most /// heavily sampled ~5km cell for that region. Lets the Elsewhere cards show /// a single "where" teaser (e.g. the city you spent the most time in) diff --git a/Where/WhereCore/Sources/ReportReader.swift b/Where/WhereCore/Sources/ReportReader.swift index 8749a247..a1f15178 100644 --- a/Where/WhereCore/Sources/ReportReader.swift +++ b/Where/WhereCore/Sources/ReportReader.swift @@ -57,6 +57,20 @@ public struct ReportReader: Sendable { return aggregator.locations(in: region, samples: samples, attributor: attributor) } + /// The recorded points for a single calendar `day`, grouped by the region + /// they attribute to, so the "Fix this day" screen and the flight-day detail + /// view can map every point of a multi-region day at once. Reads only that + /// day's samples (a half-open [start-of-day, next-day) window). Manual + /// overlays don't contribute coordinates (see `DayAggregator`). + public func locations(onDay day: Date) async throws -> [Region: [RegionDayPoint]] { + let start = aggregator.calendar.startOfDay(for: day) + guard let end = aggregator.calendar.date(byAdding: .day, value: 1, to: start) else { + return [:] + } + let samples = try await store.samples(in: DateInterval(start: start, end: end)) + return aggregator.pointsByRegion(onDay: start, samples: samples, attributor: attributor) + } + /// One representative coordinate per region for `year` — the most heavily /// sampled spot in each — so the Elsewhere cards can show a "where" teaser /// with a single geocode per region. diff --git a/Where/WhereCore/Tests/DayAggregatorTests.swift b/Where/WhereCore/Tests/DayAggregatorTests.swift index 06b2994f..54948aa6 100644 --- a/Where/WhereCore/Tests/DayAggregatorTests.swift +++ b/Where/WhereCore/Tests/DayAggregatorTests.swift @@ -236,6 +236,50 @@ struct DayAggregatorTests { #expect(first[.california] != nil) } + @Test func pointsByRegionGroupsOneDayAcrossRegions() { + // A same-day flight: two CA points, an .other fly-over, and an NY point. + let samples = [ + makeSample(at: "2026-07-14T08:00:00-07:00", lat: 37.6213, lng: -122.3790), // CA + makeSample(at: "2026-07-14T10:00:00-07:00", lat: 37.7749, lng: -122.4194), // CA + makeSample(at: "2026-07-14T13:00:00-07:00", lat: 39.53, lng: -106.16), // .other + makeSample(at: "2026-07-14T17:00:00-07:00", lat: 40.7128, lng: -74.0060), // NY + ] + let byRegion = aggregator.pointsByRegion( + onDay: startOfDay(forYear: 2026, month: 7, day: 14), + samples: samples, + attributor: attributor, + ) + #expect(byRegion[.california]?.count == 2) + #expect(byRegion[.other]?.count == 1) + #expect(byRegion[.newYork]?.count == 1) + #expect(byRegion[.canada] == nil) + } + + @Test func pointsByRegionExcludesOtherDays() { + let samples = [ + makeSample(at: "2026-07-14T10:00:00-07:00", lat: 37.7749, lng: -122.4194), + makeSample(at: "2026-07-15T10:00:00-07:00", lat: 37.7749, lng: -122.4194), + ] + let byRegion = aggregator.pointsByRegion( + onDay: startOfDay(forYear: 2026, month: 7, day: 14), + samples: samples, + attributor: attributor, + ) + #expect(byRegion[.california]?.count == 1) + } + + @Test func pointsByRegionCarryHorizontalAccuracy() { + let samples = [ + makeSample(at: "2026-07-14T10:00:00-07:00", lat: 37.7749, lng: -122.4194, accuracy: 42), + ] + let byRegion = aggregator.pointsByRegion( + onDay: startOfDay(forYear: 2026, month: 7, day: 14), + samples: samples, + attributor: attributor, + ) + #expect(byRegion[.california]?.first?.horizontalAccuracy == 42) + } + @Test func reportFiltersOtherYears() { let samples = [ makeSample(at: "2025-12-31T12:00:00-08:00", lat: 37.7749, lng: -122.4194), diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 7c3202d0..9a704060 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -458,6 +458,20 @@ public final class YearReportModel { } } + /// The recorded points for a single calendar `day`, grouped by attributed + /// region, for the "Fix this day" screen and flight-day detail view maps. + /// Logs and returns empty on failure (see `locations(in:)`). + public func locations(onDay day: Date) async -> [Region: [RegionDayPoint]] { + do { + return try await services.reports.locations(onDay: day) + } catch { + Self.logger.warning( + "Failed to load locations for day \(day) in \(selectedYear): \(error.localizedDescription)", + ) + return [:] + } + } + /// One representative coordinate per region for the selected year (the most /// heavily sampled spot in each), for the Elsewhere cards' place-name teaser. /// Logs and returns empty on failure (see `locations(in:)`). From 5cea53e472b9639526b65b67ab4d240b92715c38 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 13:03:40 -0700 Subject: [PATCH 07/11] Add bespoke flight-day detail view + Resolve routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step: flight-detail-view. FlightDayDetailView shows the day's points map (fly-over pins tinted apart from endpoints), an inference explanation, a one-tap 'Apply — keep ' authoritative overrideDay, a 'Not what you expected?' escape hatch into the regular DayRelabelView, and a 'These are all correct' dismiss. ResolutionView routes .correctFlightDay to it, with an airplane section icon and flight row copy. --- .../Resolution/FlightDayDetailView.swift | 179 ++++++++++++++++++ .../Sources/Resolution/ResolutionView.swift | 16 +- 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift diff --git a/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift b/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift new file mode 100644 index 00000000..35c5edef --- /dev/null +++ b/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift @@ -0,0 +1,179 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Detail screen for a suspected flight day: a map of the day's recorded points +/// (with the fly-over `.other` pins tinted apart from the grounded endpoints) +/// above an explanation and a one-tap fix. "Apply" writes an authoritative +/// `overrideDay` keeping only the endpoints; "Not what you expected?" hands off +/// to the regular `DayRelabelView` (seeded from the day's *actual* regions, so a +/// wrong guess isn't baked in); "These are all correct" dismisses the issue. +struct FlightDayDetailView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.stylesheet) private var stylesheet + + let issue: any DataIssue + let report: YearReportModel + let resolve: ResolveModel + + @State private var mapPoints: [RecordedMapPoint] = [] + @State private var applying = false + @State private var saveError = SaveErrorAlertState() + + var body: some View { + @Bindable var saveError = saveError + + Group { + if let payload = flightPayload { + content(payload) + .task(id: payload.day.date) { await loadPoints(for: payload.day.date) } + } else { + ContentUnavailableView( + Strings.loadErrorTitle, + systemImage: "exclamationmark.triangle", + ) + } + } + .navigationTitle(Strings.resolutionFlightDetailTitle) + .navigationBarTitleDisplayMode(.inline) + .alert( + Strings.manualSaveErrorTitle, + isPresented: $saveError.isPresented, + ) { + Button(Strings.commonOK, role: .cancel) {} + } message: { + if let message = saveError.message { + Text(message) + } + } + } + + private func content(_ payload: FlightPayload) -> some View { + VStack(spacing: 0) { + if !mapPoints.isEmpty { + RecordedPointsMap(points: mapPoints) + } + form(payload) + } + .animation(.default, value: applying) + } + + private func form(_ payload: FlightPayload) -> some View { + Form { + Section { + Text(Strings.resolutionFlightDetailExplanation( + peakSpeedKMH: payload.peakSpeedKMH, + removed: payload.removed, + )) + } header: { + Text(dateText(payload.day)) + } + + if applying { + Section { + SavingStatusRow(text: Strings.manualSavingStatus) + } + } + + Section { + Button { + apply(payload) + } label: { + Text(Strings.resolutionFlightApply(regions: payload.keep)) + } + .disabled(applying) + } + + Section { + NavigationLink { + DayRelabelView( + day: payload.day, + report: report, + reason: .flight(removed: payload.removed), + ) + } label: { + Text(Strings.resolutionFlightManualFix) + } + .disabled(applying) + } footer: { + Text(Strings.resolutionFlightManualFixFooter) + } + + Section { + Button(Strings.resolutionFlightBothRight) { + Task { + await resolve.dismiss(issue) + dismiss() + } + } + .disabled(applying) + } + } + } + + private func dateText(_ day: DayPresence) -> String { + day.date.formatted(.dateTime.month(.abbreviated).day().year()) + } + + private func loadPoints(for day: Date) async { + let byRegion = await report.locations(onDay: day) + guard !Task.isCancelled else { return } + mapPoints = byRegion.flatMap { region, points in + points.map { + RecordedMapPoint( + coordinate: $0.coordinate, + horizontalAccuracy: $0.horizontalAccuracy, + region: region, + ) + } + } + } + + private func apply(_ payload: FlightPayload) { + applying = true + saveError.message = nil + Task { + do { + try await report.overrideDay(date: payload.day.date, regions: payload.keep) + dismiss() + } catch { + // Keep the screen up so the user can retry; the fix didn't land. + saveError.message = error.localizedDescription + applying = false + } + } + } + + private var flightPayload: FlightPayload? { + if case let .correctFlightDay(day, keep, removed, peak) = issue.resolution { + return FlightPayload(day: day, keep: keep, removed: removed, peakSpeedKMH: peak) + } + return nil + } + + /// The `.correctFlightDay` resolution unpacked for the view (a named struct + /// rather than a tuple, since it escapes into `content`/`form`). + private struct FlightPayload { + let day: DayPresence + let keep: Set + let removed: Set + let peakSpeedKMH: Double + } +} + +#if DEBUG + #Preview { + NavigationStack { + FlightDayDetailView( + issue: FlightDayIssue( + day: DayPresence(date: .now, regions: [.newYork, .other, .california]), + keepRegions: [.newYork, .california], + removedRegions: [.other], + peakSpeedKMH: 880, + ), + report: PreviewSupport.loadedYearReportModel(), + resolve: PreviewSupport.resolveModel(), + ) + } + } +#endif diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index f64860c7..9d3af4ac 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -102,6 +102,7 @@ struct ResolutionView: View { case .missingDays: "calendar.badge.exclamationmark" case .borderDrift: "location.circle" case .abruptChange: "arrow.triangle.swap" + case .flightDay: "airplane" } } } @@ -144,10 +145,17 @@ private struct IssueRow: View { switch issue.resolution { case let .backfill(range): ManualDayView(report: report, mode: .add(prefill: range)) - case let .relabelDay(day, suggested, _): - DayRelabelView(day: day, report: report, initialRegions: suggested) + case let .relabelDay(day, suggested, meters): + DayRelabelView( + day: day, + report: report, + initialRegions: suggested, + reason: .borderDrift(region: suggested.first ?? .other, distanceMeters: meters), + ) case .markTravelDay: AbruptChangeDetailView(issue: issue, report: report, resolve: resolve) + case .correctFlightDay: + FlightDayDetailView(issue: issue, report: report, resolve: resolve) } } @@ -162,6 +170,8 @@ private struct IssueRow: View { earlier: earlier.regions, later: later.regions, ) + case let .correctFlightDay(day, _, _, _): + day.date.formatted(.dateTime.month(.abbreviated).day().year()) } } @@ -173,6 +183,8 @@ private struct IssueRow: View { Self.relabelSubtitle(suggested: suggested, meters: meters) case let .markTravelDay(_, later, _): later.date.formatted(.dateTime.month(.abbreviated).day().year()) + case .correctFlightDay: + Strings.resolutionFlightRowSubtitle } } From 068e148a3616c34c92ea4a6eff8b8ae4f5356a0a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 13:03:59 -0700 Subject: [PATCH 08/11] Add map + why-banner to 'Fix this day' Plan step: enrich-fix-screen. DayRelabelView now shows RecordedPointsMap of the day's recorded points at the top and an optional DayRelabelReason banner explaining why the day was flagged. The reason is threaded from each entry point: border drift (ResolutionView), travel day (AbruptChangeDetailView), flight (FlightDayDetailView's 'Not what you expected?'), and none for plain Elsewhere browsing (RegionDaysView). --- .../Resolution/AbruptChangeDetailView.swift | 2 + .../Sources/Secondary/DayRelabelView.swift | 138 +++++++++++++++--- 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift index 8fc660b0..b38c26fe 100644 --- a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift +++ b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift @@ -26,6 +26,7 @@ struct AbruptChangeDetailView: View { day: payload.earlier, report: report, initialRegions: payload.suggested, + reason: .travelDay, ) } label: { Text(Strings.resolutionAbruptDetailRelabelEarlier) @@ -39,6 +40,7 @@ struct AbruptChangeDetailView: View { day: payload.later, report: report, initialRegions: payload.suggested, + reason: .travelDay, ) } label: { Text(Strings.resolutionAbruptDetailRelabelLater) diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index a217a7c8..67173ec7 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -2,21 +2,37 @@ import RegionKit import SwiftUI import WhereCore +/// Why the user landed on "Fix this day", so the screen can show a short banner +/// explaining the flagged problem. `.none` is the plain browsing entry (the +/// Elsewhere drill-in) — no banner. +enum DayRelabelReason: Hashable { + case none + case borderDrift(region: Region, distanceMeters: Double?) + case travelDay + case flight(removed: Set) +} + /// Correct which regions a single day counted for. Unlike `ManualDayView` /// (which unions with GPS to backfill / edits a hand-logged entry), saving here /// *overrides* the day — it replaces whatever GPS or a prior entry recorded, so /// a wrong attribution can be removed. The raw GPS samples are left untouched /// (see `DayJournal.overrideDay`), so the fix is reversible. +/// +/// A map of the day's recorded points sits at the top (so the user can see +/// where the fixes actually were), and an optional `reason` banner explains why +/// the day was flagged. struct DayRelabelView: View { @Environment(\.dismiss) private var dismiss let day: DayPresence let report: YearReportModel + let reason: DayRelabelReason @State private var regionSelection: RegionSelectionState @State private var note = "" @State private var saveError = SaveErrorAlertState() @State private var pending: PendingWrite? + @State private var mapPoints: [RecordedMapPoint] = [] /// Which async write is in flight. Saving captures a one-shot GPS fix (see /// `LocationSource.requestCurrentLocation()`) so it can take a moment and @@ -26,9 +42,15 @@ struct DayRelabelView: View { case resetting } - init(day: DayPresence, report: YearReportModel, initialRegions: Set? = nil) { + init( + day: DayPresence, + report: YearReportModel, + initialRegions: Set? = nil, + reason: DayRelabelReason = .none, + ) { self.day = day self.report = report + self.reason = reason _regionSelection = State( initialValue: RegionSelectionState(selectedRegions: initialRegions ?? day.regions), ) @@ -42,7 +64,41 @@ struct DayRelabelView: View { var body: some View { @Bindable var saveError = saveError + VStack(spacing: 0) { + if !mapPoints.isEmpty { + RecordedPointsMap(points: mapPoints) + } + form + } + .navigationTitle(Strings.relabelTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + if pending == .saving { + ProgressView() + } else { + Button(Strings.manualSave) { save() } + .disabled(!canSave) + } + } + } + .alert( + Strings.manualSaveErrorTitle, + isPresented: $saveError.isPresented, + ) { + Button(Strings.commonOK, role: .cancel) {} + } message: { + if let saveError = saveError.message { + Text(saveError) + } + } + .task(id: day.date) { await loadPoints() } + } + + private var form: some View { Form { + reasonBanner + Section { LabeledContent(Strings.relabelTitle, value: dateText) } @@ -87,26 +143,60 @@ struct DayRelabelView: View { } } .animation(.default, value: pending) - .navigationTitle(Strings.relabelTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - if pending == .saving { - ProgressView() - } else { - Button(Strings.manualSave) { save() } - .disabled(!canSave) - } + } + + /// A short callout explaining why the day was flagged, driven by the + /// classifier that routed here. `.none` (plain Elsewhere browsing) shows + /// nothing. + @ViewBuilder + private var reasonBanner: some View { + switch reason { + case .none: + EmptyView() + case let .borderDrift(region, meters): + banner( + icon: "location.circle", + text: Strings.relabelReasonBorderDrift( + region: region.localizedName, + distance: meters.map(Self.formattedDistance), + ), + ) + case .travelDay: + banner(icon: "arrow.triangle.swap", text: Strings.relabelReasonTravelDay) + case let .flight(removed): + banner(icon: "airplane", text: Strings.relabelReasonFlight(removed: removed)) + } + } + + private func banner(icon: String, text: String) -> some View { + Section { + Label { + Text(text) + } icon: { + Image(systemName: icon) + .foregroundStyle(.tint) } + .font(.subheadline) + } header: { + Text(Strings.relabelReasonTitle) } - .alert( - Strings.manualSaveErrorTitle, - isPresented: $saveError.isPresented, - ) { - Button(Strings.commonOK, role: .cancel) {} - } message: { - if let saveError = saveError.message { - Text(saveError) + } + + private static func formattedDistance(_ meters: Double) -> String { + Measurement(value: meters, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .road)) + } + + private func loadPoints() async { + let byRegion = await report.locations(onDay: day.date) + guard !Task.isCancelled else { return } + mapPoints = byRegion.flatMap { region, points in + points.map { + RecordedMapPoint( + coordinate: $0.coordinate, + horizontalAccuracy: $0.horizontalAccuracy, + region: region, + ) } } } @@ -170,6 +260,16 @@ struct DayRelabelView: View { } } + #Preview("Flight reason banner") { + NavigationStack { + DayRelabelView( + day: DayPresence(date: .now, regions: [.newYork, .other, .california]), + report: PreviewSupport.loadedYearReportModel(), + reason: .flight(removed: [.other]), + ) + } + } + #Preview("With audit record") { NavigationStack { DayRelabelView( From 6b7d279ef2a74daa59dff1af8a3769dee983b766 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 13:04:34 -0700 Subject: [PATCH 09/11] Add flight strings, previews, tests, docs Plan step: strings-previews-docs. Adds WhereUI Strings for the flight section/row, the FlightDayDetailView copy, and the DayRelabelReason banners; seeds a FlightDayIssue in PreviewSupport (and thus the flight detail/relabel previews); adds an end-to-end ResolveModel flight test; and documents the detector + DataIssueInput.daySamples in WhereCore's README/AGENTS. --- Where/WhereCore/AGENTS.md | 6 + Where/WhereCore/README.md | 7 +- .../Sources/Preview/PreviewSupport.swift | 7 ++ Where/WhereUI/Sources/Shared/Strings.swift | 112 ++++++++++++++++++ Where/WhereUI/Tests/ResolveModelTests.swift | 68 +++++++++++ 5 files changed, 198 insertions(+), 2 deletions(-) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 87533bde..708dffe8 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -46,6 +46,12 @@ internal shape. its cache on the same signal *and* is invalidated inline where a caller needs it provably fresh (see `WhereServices.reset()`), which is the deterministic half of that pair, not redundant with it. +- **Detectors read aggregated input; the speed-based one needs raw fixes.** + `DataIssueScanner` builds `DataIssueInput` from the `YearReport` plus + `daySamples` — per-day GPS fixes (`.gpsVisit` / `.gpsSignificantChange` only, + sorted by timestamp), the one field that keeps per-fix timestamps. Manual and + evidence-implied samples are excluded so `FlightDayDetector`'s speed math + isn't skewed by user-asserted timestamps. - **`LocationSource` abstracts GPS.** Production is `CoreLocationSource` (Visits + significant-change); tests/previews use `ScriptedLocationSource`. The one-shot `requestCurrentLocation()` returns `nil` (never throws) when no fix diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 55623dab..67b626c6 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -56,8 +56,11 @@ one it belongs to rather than to a god-object: ### Detection, notifications & the rest - **`DataIssueScanner`** + the `DataIssue` family (missing days, border drift, - abrupt change) — the "Resolve" tab's detections and their `IssueResolution` - fixes; dismissals persist under a stable, device-independent `storageKey`. + abrupt change, flight days) — the "Resolve" tab's detections and their + `IssueResolution` fixes; dismissals persist under a stable, device-independent + `storageKey`. The `FlightDayDetector` reads the per-day GPS fixes the scanner + puts on `DataIssueInput.daySamples` (timestamped, GPS-only) to spot + cruise-speed points that added a spurious region. - **Reconcilers** — `ReminderReconciler` (daily logging reminder + app-icon badge), `DailySummaryReconciler` (year-to-date recap), `DataIssueAlertReconciler` ("issues to resolve"). diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 68d64213..59b9df78 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -147,6 +147,7 @@ let start = calendar.date(from: DateComponents(year: year, month: 3, day: 1))! let day2 = calendar.date(byAdding: .day, value: 1, to: start)! let day3 = calendar.date(byAdding: .day, value: 2, to: start)! + let day4 = calendar.date(byAdding: .day, value: 3, to: start)! return [ MissingDaysIssue(range: MissingDayRange(start: start, end: start, dayCount: 1)), BorderDriftIssue( @@ -158,6 +159,12 @@ earlierDay: DayPresence(date: day2, regions: [.california]), laterDay: DayPresence(date: day3, regions: [.newYork]), ), + FlightDayIssue( + day: DayPresence(date: day4, regions: [.newYork, .other, .california]), + keepRegions: [.newYork, .california], + removedRegions: [.other], + peakSpeedKMH: 880, + ), ] } diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 064335d2..6d98d2f2 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -1503,6 +1503,12 @@ enum Strings { defaultValue: "Sudden moves", bundle: .module, ) + case .flightDay: + String( + localized: "resolution.section.flightDay", + defaultValue: "Flights", + bundle: .module, + ) } } @@ -1581,6 +1587,112 @@ enum Strings { ) } + static var resolutionFlightRowSubtitle: String { + String( + localized: "resolution.flight.rowSubtitle", + defaultValue: "Looks like a flight", + bundle: .module, + ) + } + + static var resolutionFlightDetailTitle: String { + String( + localized: "resolution.flight.detail.title", + defaultValue: "Looks like a flight", + bundle: .module, + ) + } + + static func resolutionFlightDetailExplanation( + peakSpeedKMH: Double, + removed: Set, + ) -> String { + let speed = Measurement(value: peakSpeedKMH, unit: UnitSpeed.kilometersPerHour) + .formatted(.measurement(width: .abbreviated, usage: .general)) + let removedNames = removed.map(\.localizedName).sorted().joined(separator: ", ") + return String( + localized: "resolution.flight.detail.explanation", + defaultValue: + "Some GPS points crossed the map at about \(speed) — that usually means a flight, not somewhere you actually stopped. Applying this keeps where you took off and landed and drops \(removedNames).", + bundle: .module, + ) + } + + static func resolutionFlightApply(regions: Set) -> String { + let names = regions.map(\.localizedName).sorted().joined(separator: ", ") + return String( + localized: "resolution.flight.apply", + defaultValue: "Keep \(names)", + bundle: .module, + ) + } + + static var resolutionFlightManualFix: String { + String( + localized: "resolution.flight.manualFix", + defaultValue: "Not what you expected?", + bundle: .module, + ) + } + + static var resolutionFlightManualFixFooter: String { + String( + localized: "resolution.flight.manualFix.footer", + defaultValue: "If this wasn't a flight, or you'd rather set the regions yourself, fix the day by hand.", + bundle: .module, + ) + } + + static var resolutionFlightBothRight: String { + String( + localized: "resolution.flight.bothRight", + defaultValue: "These are all correct", + bundle: .module, + ) + } + + // MARK: - "Fix this day" reason banner + + static func relabelReasonBorderDrift(region: String, distance: String?) -> String { + if let distance { + return String( + localized: "relabel.reason.borderDrift.distance", + defaultValue: "Some points sit about \(distance) outside \(region) — likely GPS drift near the border.", + bundle: .module, + ) + } + return String( + localized: "relabel.reason.borderDrift", + defaultValue: "Some points look like GPS drift near the \(region) border.", + bundle: .module, + ) + } + + static var relabelReasonTravelDay: String { + String( + localized: "relabel.reason.travelDay", + defaultValue: "The days around this one don't overlap — set where you were if you were traveling.", + bundle: .module, + ) + } + + static func relabelReasonFlight(removed: Set) -> String { + let removedNames = removed.map(\.localizedName).sorted().joined(separator: ", ") + return String( + localized: "relabel.reason.flight", + defaultValue: "This looked like a flight — high-speed points added \(removedNames). Set where you actually were.", + bundle: .module, + ) + } + + static var relabelReasonTitle: String { + String( + localized: "relabel.reason.title", + defaultValue: "Why this needs a look", + bundle: .module, + ) + } + static var settingsResolutionHeader: String { String( localized: "settings.resolution.header", diff --git a/Where/WhereUI/Tests/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift index bd7636d0..7933f06e 100644 --- a/Where/WhereUI/Tests/ResolveModelTests.swift +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import TestHostSupport import Testing @_spi(Testing) import WhereCore @@ -78,6 +79,73 @@ struct ResolveModelTests { #expect(keys.contains(issue.id.storageKey)) } + /// End-to-end: seeded cruise-speed GPS fixes for one day surface a + /// `.flightDay` issue through the real scanner, keeping the endpoints and + /// dropping the fly-over `.other`. + @Test func loadSurfacesFlightDayIssue() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 6, day: 15) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + let resolve = ResolveModel( + services: services, + preferences: WherePreferences(store: InMemoryKeyValueStore()), + ) + + let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) + let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) + let illinois = Coordinate(latitude: 40.29, longitude: -90.39) + let colorado = Coordinate(latitude: 39.53, longitude: -106.16) + let nevada = Coordinate(latitude: 38.68, longitude: -116.90) + let fixes: [(Double, Coordinate)] = [ + (8.0, jfk), + (8.5, jfk), + (12.0, jfk), + (13.5, illinois), + (15.0, colorado), + (16.5, nevada), + (17.5, sfo), + (18.0, sfo), + ] + try await store.perform { + for (hour, coordinate) in fixes { + try await store.add(sample: LocationSample( + timestamp: flightSampleDate(hour: hour), + coordinate: coordinate, + horizontalAccuracy: 20, + source: .gpsSignificantChange, + )) + } + } + await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) + + let flight = try #require(resolve.dataIssues.first { $0.category == .flightDay }) + guard case let .correctFlightDay(_, keep, removed, peak) = flight.resolution else { + Issue.record("expected correctFlightDay resolution") + return + } + #expect(keep == [.newYork, .california]) + #expect(removed == [.other]) + #expect(peak > 300) + } + + private func flightSampleDate(hour: Double) -> Date { + let wholeHour = Int(hour) + let minutes = Int((hour - Double(wholeHour)) * 60) + return Calendar.current.date(from: DateComponents( + year: 2026, + month: 3, + day: 15, + hour: wholeHour, + minute: minutes, + ))! + } + /// The empty-state guard: `hasLoaded` starts false and flips once the first /// scan lands, so `ResolutionView` can hold a spinner instead of flashing /// "all clear" under a non-zero badge. From ddd331e516e43d0c922c85019f87bd7607aff7fd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 15:26:27 -0700 Subject: [PATCH 10/11] Add flight-day test coverage from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review findings 1-4 (missing tests): 1. applyingFlightFixClearsTheIssue — idempotence: the one-tap authoritative overrideDay clears the issue on rescan. 2. flightDetectionIgnoresManualAndEvidenceSamples — the coast-to-coast pattern recorded as manual/evidence fixes produces no flight issue (gpsSamplesByDay excludes non-GPS sources from the speed math). 3. keepsRegionWithALegitimateLayover — a genuine .other dwell keeps the region grounded, so a flight with fly-over .other points isn't stripped. 4. flightDayDetailViewHosts — hosts FlightDayDetailView without crashing. Also refactors ResolveModelTests to share a flight-seeding helper. --- .../Tests/DataIssueScannerTests.swift | 51 +++++++++++++ .../Tests/FlightDayDetectorTests.swift | 25 +++++++ Where/WhereUI/Tests/ResolveModelTests.swift | 71 +++++++++++++++---- Where/WhereUI/Tests/ScreenHostingTests.swift | 19 +++++ 4 files changed, 151 insertions(+), 15 deletions(-) diff --git a/Where/WhereCore/Tests/DataIssueScannerTests.swift b/Where/WhereCore/Tests/DataIssueScannerTests.swift index 2eb50cfe..1881d0de 100644 --- a/Where/WhereCore/Tests/DataIssueScannerTests.swift +++ b/Where/WhereCore/Tests/DataIssueScannerTests.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit import Testing @testable import WhereCore @@ -17,6 +18,10 @@ struct DataIssueScannerTests { ))!) } + private static func time(_ year: Int, _ month: Int, _ day: Int, _ hour: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))! + } + private func makeServices(now: @escaping @Sendable () -> Date) throws -> WhereServices { let store = try SwiftDataStore.inMemory() return WhereServices( @@ -46,6 +51,52 @@ struct DataIssueScannerTests { ) } + /// The speed-based `FlightDayDetector` must ignore manual and + /// evidence-implied samples: their timestamps are user-asserted, so a speed + /// computed across them is meaningless. The exact coast-to-coast pattern + /// that flags as GPS produces no flight issue when recorded as manual / + /// evidence fixes (the scanner's `gpsSamplesByDay` filters them out). + @Test func flightDetectionIgnoresManualAndEvidenceSamples() async throws { + let store = try SwiftDataStore.inMemory() + let fixedNow = Self.day(2026, 6, 15) + let scanner = makeScanner(store: store, now: { fixedNow }) + + let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) + let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) + let illinois = Coordinate(latitude: 40.29, longitude: -90.39) + let colorado = Coordinate(latitude: 39.53, longitude: -106.16) + let nevada = Coordinate(latitude: 38.68, longitude: -116.90) + let evidence = SampleSource.evidenceImplied(id: UUID(), kind: .other(nil)) + let fixes: [(Int, Coordinate, SampleSource)] = [ + (8, jfk, .manual), + (9, jfk, .manual), + (12, jfk, .manual), + (13, illinois, .manual), + (14, colorado, .manual), + (15, nevada, evidence), + (17, sfo, .manual), + (18, sfo, .manual), + ] + try await store.perform { + for (hour, coordinate, source) in fixes { + try await store.add(sample: LocationSample( + timestamp: Self.time(2026, 3, 15, hour), + coordinate: coordinate, + horizontalAccuracy: 20, + source: source, + )) + } + } + + let issues = try await scanner.issues( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 10000, + force: true, + ) + #expect(!issues.contains { $0.category == .flightDay }) + } + @Test func issues_returnsSortedIssues() async throws { let fixedNow = Self.day(2026, 6, 15) let services = try makeServices(now: { fixedNow }) diff --git a/Where/WhereCore/Tests/FlightDayDetectorTests.swift b/Where/WhereCore/Tests/FlightDayDetectorTests.swift index 5eba1bad..5914b3cf 100644 --- a/Where/WhereCore/Tests/FlightDayDetectorTests.swift +++ b/Where/WhereCore/Tests/FlightDayDetectorTests.swift @@ -47,6 +47,31 @@ struct FlightDayDetectorTests { #expect(peak > 300) } + /// A flight with a genuine layover in an untracked region: even with two + /// fly-over `.other` fixes, a real `.other` stop (dwell fixes in Chicago) + /// keeps `.other` grounded, so nothing is removed and the day isn't flagged. + /// The detector must not strip a region the user actually stopped in. + @Test func keepsRegionWithALegitimateLayover() { + let date = Fixtures.day(2026, 7, 14) + let chicago = Coordinate(latitude: 41.8781, longitude: -87.6298) // .other, with dwell + let day = DayPresence(date: date, regions: [.newYork, .other, .california]) + let samples = [ + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 8.0, Self.jfk), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 9.0, Self.jfk), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 11.0, Self.colorado), // fly-over + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 12.5, Self.nevada), // fly-over + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 14.0, chicago), // land + dwell + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 15.0, chicago), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 16.0, chicago), + Fixtures.gpsSample(dayStart: date, hoursAfterStart: 19.0, Self.sfo), + ] + let issues = FlightDayDetector().detectIssues(in: Fixtures.input( + days: [day], + daySamples: [date: samples], + )) + #expect(issues.isEmpty) + } + /// A fast road trip that dips into `.other` but never reaches cruise speed: /// no leg clears the threshold, so nothing is a fly-over point. @Test func ignoresFastDrivingBelowThreshold() { diff --git a/Where/WhereUI/Tests/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift index 7933f06e..2efebc8a 100644 --- a/Where/WhereUI/Tests/ResolveModelTests.swift +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -85,18 +85,69 @@ struct ResolveModelTests { @Test func loadSurfacesFlightDayIssue() async throws { let store = try TestStore() let now = date(year: 2026, month: 6, day: 15) - let services = WhereServices( + let services = flightServices(store: store, now: now) + let resolve = ResolveModel( + services: services, + preferences: WherePreferences(store: InMemoryKeyValueStore()), + ) + + try await seedCoastToCoastFlight(into: store) + await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) + + let flight = try #require(resolve.dataIssues.first { $0.category == .flightDay }) + guard case let .correctFlightDay(_, keep, removed, peak) = flight.resolution else { + Issue.record("expected correctFlightDay resolution") + return + } + #expect(keep == [.newYork, .california]) + #expect(removed == [.other]) + #expect(peak > 300) + } + + /// Idempotence: applying the one-tap fix (an authoritative `overrideDay` to + /// the kept regions, exactly what `FlightDayDetailView` does) clears the + /// issue — a rescan no longer flags the day, so "Apply" visibly resolves it + /// rather than leaving a sticky row. + @Test func applyingFlightFixClearsTheIssue() async throws { + let store = try TestStore() + let now = date(year: 2026, month: 6, day: 15) + let services = flightServices(store: store, now: now) + let resolve = ResolveModel( + services: services, + preferences: WherePreferences(store: InMemoryKeyValueStore()), + ) + + try await seedCoastToCoastFlight(into: store) + await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) + + let flight = try #require(resolve.dataIssues.first { $0.category == .flightDay }) + guard case let .correctFlightDay(day, keep, _, _) = flight.resolution else { + Issue.record("expected correctFlightDay resolution") + return + } + + // Apply the fix the same way the detail view's "Apply" button does, then + // drop the scanner cache so the reload sees a fresh scan. + try await services.journal.overrideDay(date: day.date, regions: keep, audit: nil) + await services.resolution.invalidate() + await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) + + #expect(!resolve.dataIssues.contains { $0.category == .flightDay }) + } + + private func flightServices(store: TestStore, now: Date) -> WhereServices { + WhereServices( store: store, locationSource: ScriptedLocationSource(), reminderScheduler: NoopLoggingReminderScheduler(), widgetRefresher: NoopWidgetTimelineRefresher(), now: { now }, ) - let resolve = ResolveModel( - services: services, - preferences: WherePreferences(store: InMemoryKeyValueStore()), - ) + } + /// Seed the NYC→SF cruise profile as passive GPS fixes: grounded NY in the + /// morning, cruise-speed legs across untracked states, then grounded SFO. + private func seedCoastToCoastFlight(into store: TestStore) async throws { let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) let illinois = Coordinate(latitude: 40.29, longitude: -90.39) @@ -122,16 +173,6 @@ struct ResolveModelTests { )) } } - await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) - - let flight = try #require(resolve.dataIssues.first { $0.category == .flightDay }) - guard case let .correctFlightDay(_, keep, removed, peak) = flight.resolution else { - Issue.record("expected correctFlightDay resolution") - return - } - #expect(keep == [.newYork, .california]) - #expect(removed == [.other]) - #expect(peak > 300) } private func flightSampleDate(hour: Double) -> Date { diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index edfae25d..336a1753 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -126,6 +126,25 @@ struct ScreenHostingTests { } } + @Test func flightDayDetailViewHosts() throws { + let issue = FlightDayIssue( + day: DayPresence(date: .now, regions: [.newYork, .other, .california]), + keepRegions: [.newYork, .california], + removedRegions: [.other], + peakSpeedKMH: 880, + ) + let rootView = NavigationStack { + FlightDayDetailView( + issue: issue, + report: PreviewSupport.loadedYearReportModel(), + resolve: PreviewSupport.resolveModel(), + ) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + @Test func recentActivitySummaryViewHostsEachState() throws { for state in [ RecentActivityModel.LoadState.loaded("You were in California, then New York."), From 79ffc53d28f259d2a77e8d145673fdfb86817d98 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 16:06:45 -0700 Subject: [PATCH 11/11] Adapt flight code to RegionAttributing protocol (#87) Second main merge brought PR #87, which turned RegionAttributor into the 'any RegionAttributing' protocol across the detection/aggregation APIs. Point the two flight-feature params added by this branch (DayAggregator.pointsByRegion, FlightDayDetector.flightIssue) at the protocol so they accept the store-backed attributor callers now pass. --- Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift | 2 +- Where/WhereCore/Sources/Days/DayAggregator.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift b/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift index 0bfd57c1..fd626dbe 100644 --- a/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift +++ b/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift @@ -60,7 +60,7 @@ public struct FlightDayDetector: DataIssueDetector { private func flightIssue( for day: DayPresence, samples: [LocationSample], - attributor: RegionAttributor, + attributor: any RegionAttributing, ) -> FlightDayIssue? { guard samples.count >= 3 else { return nil } diff --git a/Where/WhereCore/Sources/Days/DayAggregator.swift b/Where/WhereCore/Sources/Days/DayAggregator.swift index 85679091..17b6c6f8 100644 --- a/Where/WhereCore/Sources/Days/DayAggregator.swift +++ b/Where/WhereCore/Sources/Days/DayAggregator.swift @@ -76,7 +76,7 @@ public struct DayAggregator: Sendable { public func pointsByRegion( onDay day: CalendarDay, samples: [LocationSample], - attributor: RegionAttributor, + attributor: any RegionAttributing, ) -> [Region: [RegionDayPoint]] { var byRegion: [Region: [RegionDayPoint]] = [:] for sample in samples where CalendarDay(from: sample.timestamp, in: calendar) == day {