Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Where/RegionKit/Sources/Coordinate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Coordinate> {
Expand Down
28 changes: 28 additions & 0 deletions Where/RegionKit/Tests/CoordinateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
6 changes: 6 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
15 changes: 15 additions & 0 deletions Where/WhereCore/Sources/DataResolution/DataIssue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,12 +15,24 @@ public enum IssueResolution: Sendable, Hashable {
case backfill(MissingDayRange)
case relabelDay(day: DayPresence, suggestedRegions: Set<Region>, approximateMeters: Double?)
case markTravelDay(earlier: DayPresence, later: DayPresence, suggestedRegions: Set<Region>)
/// 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<Region>,
removedRegions: Set<Region>,
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
Expand All @@ -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)"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift
Original file line number Diff line number Diff line change
@@ -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<Region> = []
var hasGrounded: Set<Region> = []
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,
)
}
}
51 changes: 51 additions & 0 deletions Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift
Original file line number Diff line number Diff line change
@@ -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<Region>
public let removedRegions: Set<Region>
public let peakSpeedKMH: Double

public init(
day: DayPresence,
keepRegions: Set<Region>,
removedRegions: Set<Region>,
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,
)
}
}
Loading
Loading