Ghost Car tries to answer one stubborn question: which drive was actually faster? The honest part is hard, because real drives never start clean. You tap record, sit in the driveway for a minute, and at the far end you coast into a parking spot and forget to stop the timer. Line two recordings up against each other and that dead time at the front and back quietly skews the result. So I added a small piece of logic that earned its keep faster than I expected: optional idle trimming. What made it interesting was the data I had to work with, because a drive in Ghost Car does not store speed at all.
What a drive actually is
I assumed, before I looked closely, that I would have a nice per-point record: coordinate, timestamp, speed. That is not what gets saved. A Drive is a SwiftData @Model, and the only path data it persists is a blob of encoded coordinate pairs plus a few aggregates.
@Model
final class Drive: Identifiable {
var duration: TimeInterval = 0 // whole-drive seconds
var distance: Double = 0 // miles
var coordinatesData: Data = Data() // JSON: [{"lat": .., "lon": ..}]
// decoded on demand into CoreLocation coordinates
nonisolated var coordinates: [CLLocationCoordinate2D] { /* decode */ }
}
So every point is a bare CLLocationCoordinate2D: latitude and longitude, nothing else. There is no timestamp on a point and no speed on a point. The only timing I have is duration for the entire drive. That constraint shaped the whole feature, because to decide whether the car was idling at a given point, I first had to invent a notion of speed that the data never recorded.
The dead time at both ends
The interesting part of any drive is the middle, where the car is moving. The less interesting part lives at the edges: the seconds before you pull out, and the seconds after you arrive but before you reach for the phone. That edge time is not consistent between drives. One morning you leave right away; the next, you scrape ice off the windshield with the timer running.
When the comparison engine lines two drives up and reports elapsed times, those idle stretches go straight into the numbers. A route that was genuinely quicker on the road can lose on the scoreboard purely because you idled longer before rolling. The comparison starts to feel arbitrary rather than honest, which defeats the only thing the app is for.
Reconstructing speed from points alone
Since no point carries a speed, I derive one. I assume the recorder sampled at a roughly even cadence, so I spread the drive's total duration across the gaps between points to get an average time per point. Then for any two neighbors I take the great-circle distance between them, using CLLocation.distance(from:), and divide by that per-point time. A unit conversion turns meters per second into miles per hour so the threshold reads in the same units a driver thinks in.
let avgTimePerPoint = drive.duration / Double(coords.count - 1)
let meters = CLLocation(latitude: a.latitude, longitude: a.longitude)
.distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude))
// meters -> miles, seconds -> hours
let mph = (meters / 1609.34) / (avgTimePerPoint / 3600.0)
It is an approximation, and I want to be honest that it is one. Using an averaged cadence means a point recorded during a GPS stall looks slower than it really was, and a sparse stretch looks faster. For the question I am asking, though, the approximation is plenty. I am not measuring instantaneous velocity; I am only trying to tell "parked" apart from "rolling," and a car in a driveway sits well under the default threshold of 5 mph no matter how I average the time.
Scanning inward from each end
With a per-pair speed in hand, detecting idle is a short scan. From the front I walk forward, counting points until the first one where the car is clearly moving, then stop. From the back I walk backward by the same rule. The function returns a Range<Int> describing the idle run at that end: 0..<idleCount for the start, and (count - idleCount)..<count for the end.
The key word is contiguous. The loop breaks the moment it sees motion, so it can only ever eat into the leading and trailing edges. A drive that sits at a long red light mid-route is never touched, because by the time the engine reaches that light it has already broken out of the leading scan. That stop is part of the trip, and trimming it would punish a route for honest traffic. Scanning strictly inward from each end is what keeps the middle of the drive sacred.
Move the index, never the data
The design choice that makes this feature feel safe is that trimming adjusts indices, not coordinates. A drive in the comparison engine is wrapped in a DriveSegment, a small value type that holds a reference to the original Drive, a startIndex, an endIndex, and a timeOffset used later for alignment. Its visible coordinates are just a slice: Array(coords[startIndex...endIndex]). Trimming advances startIndex past the leading idle run and pulls endIndex back ahead of the trailing one.
if settings.trimStartIdle {
let idle = detectIdlePeriod(drive, at: .start, threshold: settings.idleSpeedThreshold)
segment.startIndex = idle.upperBound // first moving point
}
if settings.trimEndIdle {
let idle = detectIdlePeriod(drive, at: .end, threshold: settings.idleSpeedThreshold)
segment.endIndex = idle.lowerBound // last moving point
}
The stored recording is never edited. I do not delete points, rewrite the encoded coordinatesData, or recompute the saved distance. I only change which window of the existing array the comparison reads from. The segment's duration follows along for free, because it is computed as Double(endIndex - startIndex) multiplied by the per-point time, so a shorter window simply reports a shorter elapsed time.
Because nothing destructive happens, the two toggles, trimStartIdle and trimEndIdle, are pure lenses. You can flip them on and off freely; there is no re-import and no recalculation of the saved drive to regret. The raw recording stays the single source of truth. By default I trim the start and leave the end alone, since the slow coast into a parking spot is sometimes part of the route you care about, and I would rather under-trim than quietly delete real driving.
Why a tiny function carries weight
The whole thing is maybe a dozen lines of real logic, and it would be easy to wave off as trivial. But it sits right on the path between a comparison you trust and one you quietly stop believing. Two properties make it pull its weight.
- It is undoable by construction. Operating on indices rather than mutating data means trimming is a toggle, not a commitment. You can check the exact difference by turning it off.
- It is pure, isolation-free computation.
detectIdlePeriodis anonisolated staticfunction that takes a drive and a threshold and returns a range. It touches no SwiftUI state and no SwiftData context, so it can run off the main actor and is trivial to unit test against a hand-built point array.
The features that decide whether people trust a tool are rarely the big ones. They are the small, quiet corrections that keep the numbers honest.
Takeaways
- If your data does not store the quantity you need, derive it, but be honest about the approximation. Averaging total duration across points is rough, yet more than good enough to separate parked from rolling.
- Trim by behavior, not by clock. A speed threshold removes parked time without erasing legitimate stops mid-route.
- Scan strictly inward from both ends and break on the first motion, so only the leading and trailing idle can be touched.
- Adjust indices, leave the recording untouched. That single choice makes the feature a reversible lens and keeps the original data trustworthy.
- Keep the logic pure and off the main actor. No UI, no model mutation means it stays testable and easy to reason about.
A clean comparison is the whole point of the app. Idle trimming is how I try to keep that promise without ever rewriting what really happened on the road, even when the road, as recorded, is nothing more than a string of dots.