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) + } } diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index dcc1e87c..88620148 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -72,6 +72,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. - **Post-write reconciliation is defined once.** Every write and import routes through `DayJournal.reconcileAfterDayChange()` (or its widget-less subset `reconcileIssueState()` for dismiss/restore paths) — never copy the reconcile diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 495550d6..e5030d8e 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -72,10 +72,12 @@ 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- and timezone-independent - `storageKey` (a `CalendarDay` ISO string), so a dismissal doesn't reappear - after travel. + abrupt change, flight days) — the "Resolve" tab's detections and their + `IssueResolution` fixes; dismissals persist under a stable, device- and + timezone-independent `storageKey` (a `CalendarDay` ISO string), so a dismissal + doesn't reappear after travel. 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/WhereCore/Sources/DataResolution/DataIssue.swift b/Where/WhereCore/Sources/DataResolution/DataIssue.swift index 4cabe414..24c505d4 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: CalendarDay) case borderDrift(day: CalendarDay) case abruptChange(earlier: CalendarDay, later: CalendarDay) + case flightDay(day: CalendarDay) /// Stable, device- and timezone-independent key for persisted dismissal and /// `ForEach`. Keyed by the `CalendarDay` ISO string so a dismissal survives a @@ -36,6 +49,8 @@ public enum DataIssueID: Hashable, Sendable { "borderDrift:\(day)" case let .abruptChange(earlier, later): "abruptChange:\(earlier):\(later)" + case let .flightDay(day): + "flightDay:\(day)" } } } diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift b/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift index e0903bc9..ce9963ad 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: [CalendarDay: [Coordinate]] + /// Passive GPS fixes for the year keyed by `CalendarDay`, 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: [CalendarDay: [LocationSample]] public let primaryRegions: [Region] public let attributor: any RegionAttributing public let driftThresholdMeters: Double @@ -31,6 +38,7 @@ public struct DataIssueInput: Sendable { year: Int, report: YearReport, otherDayCoordinates: [CalendarDay: [Coordinate]], + daySamples: [CalendarDay: [LocationSample]], primaryRegions: [Region], attributor: any RegionAttributing, 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 170a94dd..903defc2 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 @@ -100,11 +101,16 @@ public actor DataIssueScanner { let otherDayCoordinates = Dictionary( uniqueKeysWithValues: otherLocations.map { ($0.day, $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 +162,27 @@ 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, + ) -> [CalendarDay: [LocationSample]] { + var byDay: [CalendarDay: [LocationSample]] = [:] + for sample in samples { + switch sample.source { + case .gpsVisit, .gpsSignificantChange: + byDay[CalendarDay(from: sample.timestamp, in: calendar), 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/DataResolution/FlightDayDetector.swift b/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift new file mode 100644 index 00000000..fd626dbe --- /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.day] 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: any RegionAttributing, + ) -> 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..f9fe8af5 --- /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(day: day.day) + } + + public var category: DataIssueCategory { + .flightDay + } + + public var sortKey: CalendarDay { + day.day + } + + public var isDismissible: Bool { + true + } + + public var resolution: IssueResolution { + .correctFlightDay( + day: day, + keepRegions: keepRegions, + removedRegions: removedRegions, + peakSpeedKMH: peakSpeedKMH, + ) + } +} diff --git a/Where/WhereCore/Sources/Days/DayAggregator.swift b/Where/WhereCore/Sources/Days/DayAggregator.swift index 9e615663..17b6c6f8 100644 --- a/Where/WhereCore/Sources/Days/DayAggregator.swift +++ b/Where/WhereCore/Sources/Days/DayAggregator.swift @@ -66,6 +66,29 @@ public struct DayAggregator: Sendable { .sorted { $0.day < $1.day } } + /// 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: CalendarDay, + samples: [LocationSample], + attributor: any RegionAttributing, + ) -> [Region: [RegionDayPoint]] { + var byRegion: [Region: [RegionDayPoint]] = [:] + for sample in samples where CalendarDay(from: sample.timestamp, in: calendar) == day { + 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/Reporting/ReportReader.swift b/Where/WhereCore/Sources/Reporting/ReportReader.swift index 152bccea..bb8ba40c 100644 --- a/Where/WhereCore/Sources/Reporting/ReportReader.swift +++ b/Where/WhereCore/Sources/Reporting/ReportReader.swift @@ -36,6 +36,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 @@ -54,6 +62,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: CalendarDay) async throws -> [Region: [RegionDayPoint]] { + let start = day.startOfDay(in: aggregator.calendar) + 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: day, 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/DataIssueDetectorTestSupport.swift b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift index c147871e..ab8b94d8 100644 --- a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift +++ b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift @@ -23,10 +23,27 @@ enum DataIssueDetectorFixtures { CalendarDay(year: year, month: month, day: day) } + /// A passive GPS fix at `coordinate`, `hoursAfterStart` into `day`, for + /// building the timestamp-sorted `daySamples` the speed-based detector walks. + static func gpsSample( + day: CalendarDay, + hoursAfterStart: Double, + _ coordinate: Coordinate, + source: SampleSource = .gpsSignificantChange, + ) -> LocationSample { + LocationSample( + timestamp: day.startOfDay(in: calendar).addingTimeInterval(hoursAfterStart * 3600), + coordinate: coordinate, + horizontalAccuracy: 20, + source: source, + ) + } + static func input( year: Int = 2026, days: [DayPresence] = [], otherDayCoordinates: [CalendarDay: [Coordinate]] = [:], + daySamples: [CalendarDay: [LocationSample]] = [:], primaryRegions: [Region] = [.california, .newYork], driftThresholdMeters: Double = 10000, now: Date? = nil, @@ -36,6 +53,7 @@ enum DataIssueDetectorFixtures { year: year, report: YearReport(year: year, days: days, totals: totals), otherDayCoordinates: otherDayCoordinates, + daySamples: daySamples, primaryRegions: primaryRegions, attributor: RegionAttributor.shared, driftThresholdMeters: driftThresholdMeters, diff --git a/Where/WhereCore/Tests/DataIssueScannerTests.swift b/Where/WhereCore/Tests/DataIssueScannerTests.swift index b69672f0..13087251 100644 --- a/Where/WhereCore/Tests/DataIssueScannerTests.swift +++ b/Where/WhereCore/Tests/DataIssueScannerTests.swift @@ -18,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( @@ -47,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/DayAggregatorTests.swift b/Where/WhereCore/Tests/DayAggregatorTests.swift index 9530f318..532455b1 100644 --- a/Where/WhereCore/Tests/DayAggregatorTests.swift +++ b/Where/WhereCore/Tests/DayAggregatorTests.swift @@ -249,6 +249,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: CalendarDay(year: 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: CalendarDay(year: 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: CalendarDay(year: 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/WhereCore/Tests/FlightDayDetectorTests.swift b/Where/WhereCore/Tests/FlightDayDetectorTests.swift new file mode 100644 index 00000000..1001f0c0 --- /dev/null +++ b/Where/WhereCore/Tests/FlightDayDetectorTests.swift @@ -0,0 +1,135 @@ +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.calendarDay(2026, 7, 14) + let day = DayPresence(day: date, regions: [.newYork, .other, .california]) + let samples = [ + Fixtures.gpsSample(day: date, hoursAfterStart: 8.0, Self.jfk), + Fixtures.gpsSample(day: date, hoursAfterStart: 8.5, Self.jfk), + Fixtures.gpsSample(day: date, hoursAfterStart: 12.0, Self.jfk), + Fixtures.gpsSample(day: date, hoursAfterStart: 13.5, Self.illinois), + Fixtures.gpsSample(day: date, hoursAfterStart: 15.0, Self.colorado), + Fixtures.gpsSample(day: date, hoursAfterStart: 16.5, Self.nevada), + Fixtures.gpsSample(day: date, hoursAfterStart: 17.5, Self.sfo), + Fixtures.gpsSample(day: 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 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.calendarDay(2026, 7, 14) + let chicago = Coordinate(latitude: 41.8781, longitude: -87.6298) // .other, with dwell + let day = DayPresence(day: date, regions: [.newYork, .other, .california]) + let samples = [ + Fixtures.gpsSample(day: date, hoursAfterStart: 8.0, Self.jfk), + Fixtures.gpsSample(day: date, hoursAfterStart: 9.0, Self.jfk), + Fixtures.gpsSample(day: date, hoursAfterStart: 11.0, Self.colorado), // fly-over + Fixtures.gpsSample(day: date, hoursAfterStart: 12.5, Self.nevada), // fly-over + Fixtures.gpsSample(day: date, hoursAfterStart: 14.0, chicago), // land + dwell + Fixtures.gpsSample(day: date, hoursAfterStart: 15.0, chicago), + Fixtures.gpsSample(day: date, hoursAfterStart: 16.0, chicago), + Fixtures.gpsSample(day: 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() { + let date = Fixtures.calendarDay(2026, 7, 14) + let day = DayPresence(day: 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(day: date, hoursAfterStart: 9.0, newYorkCity), + Fixtures.gpsSample(day: date, hoursAfterStart: 10.0, newJersey), + Fixtures.gpsSample(day: 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.calendarDay(2026, 7, 14) + let day = DayPresence(day: date, regions: [.newYork, .other]) + let samples = [ + Fixtures.gpsSample(day: date, hoursAfterStart: 9.0, Self.jfk), + Fixtures.gpsSample(day: date, hoursAfterStart: 9.05, Self.colorado), + Fixtures.gpsSample(day: 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.calendarDay(2026, 7, 14) + let day = DayPresence(day: date, regions: [.other]) + let samples = [ + Fixtures.gpsSample(day: date, hoursAfterStart: 0.5, Self.illinois), + Fixtures.gpsSample(day: date, hoursAfterStart: 2.0, Self.colorado), + Fixtures.gpsSample(day: 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.calendarDay(2026, 7, 14) + let day = DayPresence(day: date, regions: [.newYork, .other, .california]) + let issues = FlightDayDetector().detectIssues(in: Fixtures.input(days: [day])) + #expect(issues.isEmpty) + } +} diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 999b2a75..c02ef5d9 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: CalendarDay) 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:)`). diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index c5113e1b..263844fa 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -149,6 +149,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)! let startDay = CalendarDay(from: start, in: calendar) return [ MissingDaysIssue(range: MissingDayRange( @@ -165,6 +166,16 @@ earlierDay: DayPresence(date: day2, in: calendar, regions: [.california]), laterDay: DayPresence(date: day3, in: calendar, regions: [.newYork]), ), + FlightDayIssue( + day: DayPresence( + date: day4, + in: calendar, + regions: [.newYork, .other, .california], + ), + keepRegions: [.newYork, .california], + removedRegions: [.other], + peakSpeedKMH: 880, + ), ] } diff --git a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift index a5e50ec2..c1c886ae 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/Resolution/FlightDayDetailView.swift b/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift new file mode 100644 index 00000000..a428d0a1 --- /dev/null +++ b/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift @@ -0,0 +1,186 @@ +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.day) { await loadPoints(for: payload.day.day) } + } 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.displayDate.formatted(.dateTime.month(.abbreviated).day().year()) + } + + private func loadPoints(for day: CalendarDay) 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.startOfDay(in: report.calendar), + 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, + in: .current, + 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 0a9ba616..368df82d 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.displayDate.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.displayDate.formatted(.dateTime.month(.abbreviated).day().year()) + case .correctFlightDay: + Strings.resolutionFlightRowSubtitle } } diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index 5b02a0bf..2c4d5597 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.day) { 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.day) + 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,20 @@ struct DayRelabelView: View { } } + #Preview("Flight reason banner") { + NavigationStack { + DayRelabelView( + day: DayPresence( + date: .now, + in: .current, + regions: [.newYork, .other, .california], + ), + report: PreviewSupport.loadedYearReportModel(), + reason: .flight(removed: [.other]), + ) + } + } + #Preview("With audit record") { NavigationStack { DayRelabelView( diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index b0a46646..5e84b14d 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: [CalendarDay: [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.day, $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 diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 83a53639..a027f2be 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -1504,6 +1504,12 @@ enum Strings { defaultValue: "Sudden moves", bundle: .module, ) + case .flightDay: + String( + localized: "resolution.section.flightDay", + defaultValue: "Flights", + bundle: .module, + ) } } @@ -1582,6 +1588,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..7e67dec5 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,118 @@ 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 = 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(_, 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: flightSampleDate(hour: 12), + 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 }, + ) + } + + /// 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) + 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, + )) + } + } + } + + 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. diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 1b831fee..ba928e9d 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -127,6 +127,25 @@ struct ScreenHostingTests { } } + @Test func flightDayDetailViewHosts() throws { + let issue = FlightDayIssue( + day: DayPresence(date: .now, in: .current, 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."),