Measure a reservation's real kilometres against the Traccar tracker #2

Merged
carlos merged 1 commit from feat/gps-mileage into main 2026-08-18 10:04:32 +00:00
Owner

trip.distance was purely declarative: whoever booked the car typed in how far
they drove, nothing checked it, and 4 of the 22 existing reservations have NULL
there. The tracker has been logging since 2026-06-19, so the real figure is
knowable. A pass now records it in new gps_* columns beside the declared one,
which is never overwritten — /api/trips/total and the ICS feed are untouched.

The measured window is deliberately NOT the declared start/end: people take the
car early and bring it back late, and measuring [12:00, 12:00] silently loses
those kilometres. So a reservation is widened to its whole days in Europe/Paris
and then clamped to the midpoint of the gap with the nearest non-overlapping
neighbour. A lone midday booking is measured 00:00–24:00; two bookings sharing a
day (08:00–12:00 and 14:00–20:00) split it at 13:00 and their windows touch
exactly, so every kilometre is counted once and none is lost.

That rests on a property of Traccar verified against the live server: its
reports/summary clamps at the window bounds, so adjacent windows partition the
distance exactly. Splitting 2026-08-17 into halves gave 100 + 156000 = 156100 m,
the full day, and the afternoon into thirds gave 72700 + 83300 + 0 = 156000.

Design notes:

  • reports/summary beats both alternatives. Against fetching raw positions and
    haversine-summing: one small response instead of thousands of rows, the
    device's own odometer instead of straight lines between sparse fixes, and no
    exposure to the streamed-body hazard TraccarCarLocationService documents.
    Against reports/trips: that sums 100 m lower on the 17th, because its trip
    detection drops sub-threshold movement.
  • Accept: application/json is mandatory on the report endpoints. Without it
    Traccar answers 200 with an XLSX spreadsheet, which then fails to decode as
    JSON for reasons that look nothing like the cause. The header now lives in
    TraccarHttp, extracted from fetchPositions so the load-bearing "read the body
    inside ZIO.scoped" constraint exists in exactly one place.
  • CarDistanceService is a new trait rather than a method on CarLocationService:
    that one answers "where is the car", this one "how far did it go", and keeping
    them apart leaves the parking tests' FakeCarLocationService untouched.
  • Only an answer from Traccar stamps gps_computed_at. An empty report (tracker
    dark) is an answer, so it is stamped with no distance and not retried; an
    unreachable Traccar writes nothing, so the next pass picks the reservation up.
  • The "is it finished" test is on the widened window end, not end_date —
    widening pushes the window past midnight, so a booking that ended at noon
    today is not measurable until tomorrow.
  • saveGpsDistance writes only the gps_* columns: no updated_at = NOW(), so a
    measurement never bumps the ICS LAST-MODIFIED stamp, and updateTrip's explicit
    column list means a client PUTting a Trip cannot spoof a measurement.
  • The pass reads the whole trip list on purpose: clamping needs the neighbours on
    either side, including already-measured ones and ones before the cut-off.
  • First background fiber in the app. catchAllCause sits inside repeat so a
    Traccar outage costs one pass, not the fiber for the process lifetime.

Tests: 8 cases on the window rule (whole-day widening, same-day midpoint split
with touching seams, the existing 00:00–23:59 midnight handover losing only 30 s,
multi-day, overlapping bookings left unclamped, no neighbour, never collapsing),
10 on the pass (cut-off, unclosed window, the widened window actually being
queried, declared value left alone, no re-measure, dark tracker stamped once,
failure retried, force), and 5 on Postgres over the new test_trip_gps schema
including that updated_at does not move and updateTrip cannot overwrite.

Verified end to end against the live tracker: two bookings sharing 2026-08-17
came out 0 km and 156 km over windows meeting at 11:00Z — the day's exact total,
with both declared values untouched.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

`trip.distance` was purely declarative: whoever booked the car typed in how far they drove, nothing checked it, and 4 of the 22 existing reservations have NULL there. The tracker has been logging since 2026-06-19, so the real figure is knowable. A pass now records it in new gps_* columns beside the declared one, which is never overwritten — /api/trips/total and the ICS feed are untouched. The measured window is deliberately NOT the declared start/end: people take the car early and bring it back late, and measuring [12:00, 12:00] silently loses those kilometres. So a reservation is widened to its whole days in Europe/Paris and then clamped to the midpoint of the gap with the nearest non-overlapping neighbour. A lone midday booking is measured 00:00–24:00; two bookings sharing a day (08:00–12:00 and 14:00–20:00) split it at 13:00 and their windows touch exactly, so every kilometre is counted once and none is lost. That rests on a property of Traccar verified against the live server: its reports/summary clamps at the window bounds, so adjacent windows partition the distance exactly. Splitting 2026-08-17 into halves gave 100 + 156000 = 156100 m, the full day, and the afternoon into thirds gave 72700 + 83300 + 0 = 156000. Design notes: - reports/summary beats both alternatives. Against fetching raw positions and haversine-summing: one small response instead of thousands of rows, the device's own odometer instead of straight lines between sparse fixes, and no exposure to the streamed-body hazard TraccarCarLocationService documents. Against reports/trips: that sums 100 m lower on the 17th, because its trip detection drops sub-threshold movement. - `Accept: application/json` is mandatory on the report endpoints. Without it Traccar answers 200 with an XLSX spreadsheet, which then fails to decode as JSON for reasons that look nothing like the cause. The header now lives in TraccarHttp, extracted from fetchPositions so the load-bearing "read the body inside ZIO.scoped" constraint exists in exactly one place. - CarDistanceService is a new trait rather than a method on CarLocationService: that one answers "where is the car", this one "how far did it go", and keeping them apart leaves the parking tests' FakeCarLocationService untouched. - Only an answer from Traccar stamps gps_computed_at. An empty report (tracker dark) is an answer, so it is stamped with no distance and not retried; an unreachable Traccar writes nothing, so the next pass picks the reservation up. - The "is it finished" test is on the widened window end, not end_date — widening pushes the window past midnight, so a booking that ended at noon today is not measurable until tomorrow. - saveGpsDistance writes only the gps_* columns: no `updated_at = NOW()`, so a measurement never bumps the ICS LAST-MODIFIED stamp, and updateTrip's explicit column list means a client PUTting a Trip cannot spoof a measurement. - The pass reads the whole trip list on purpose: clamping needs the neighbours on either side, including already-measured ones and ones before the cut-off. - First background fiber in the app. catchAllCause sits inside repeat so a Traccar outage costs one pass, not the fiber for the process lifetime. Tests: 8 cases on the window rule (whole-day widening, same-day midpoint split with touching seams, the existing 00:00–23:59 midnight handover losing only 30 s, multi-day, overlapping bookings left unclamped, no neighbour, never collapsing), 10 on the pass (cut-off, unclosed window, the widened window actually being queried, declared value left alone, no re-measure, dark tracker stamped once, failure retried, force), and 5 on Postgres over the new test_trip_gps schema including that updated_at does not move and updateTrip cannot overwrite. Verified end to end against the live tracker: two bookings sharing 2026-08-17 came out 0 km and 156 km over windows meeting at 11:00Z — the day's exact total, with both declared values untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measure a reservation's real kilometres against the Traccar tracker
All checks were successful
CI Pipeline / Run Tests (pull_request) Successful in 37s
CI Pipeline / Deploy to Coolify (pull_request) Has been skipped
3b91d88da9
`trip.distance` was purely declarative: whoever booked the car typed in how far
they drove, nothing checked it, and 4 of the 22 existing reservations have NULL
there. The tracker has been logging since 2026-06-19, so the real figure is
knowable. A pass now records it in new gps_* columns beside the declared one,
which is never overwritten — /api/trips/total and the ICS feed are untouched.

The measured window is deliberately NOT the declared start/end: people take the
car early and bring it back late, and measuring [12:00, 12:00] silently loses
those kilometres. So a reservation is widened to its whole days in Europe/Paris
and then clamped to the midpoint of the gap with the nearest non-overlapping
neighbour. A lone midday booking is measured 00:00–24:00; two bookings sharing a
day (08:00–12:00 and 14:00–20:00) split it at 13:00 and their windows touch
exactly, so every kilometre is counted once and none is lost.

That rests on a property of Traccar verified against the live server: its
reports/summary clamps at the window bounds, so adjacent windows partition the
distance exactly. Splitting 2026-08-17 into halves gave 100 + 156000 = 156100 m,
the full day, and the afternoon into thirds gave 72700 + 83300 + 0 = 156000.

Design notes:
- reports/summary beats both alternatives. Against fetching raw positions and
  haversine-summing: one small response instead of thousands of rows, the
  device's own odometer instead of straight lines between sparse fixes, and no
  exposure to the streamed-body hazard TraccarCarLocationService documents.
  Against reports/trips: that sums 100 m lower on the 17th, because its trip
  detection drops sub-threshold movement.
- `Accept: application/json` is mandatory on the report endpoints. Without it
  Traccar answers 200 with an XLSX spreadsheet, which then fails to decode as
  JSON for reasons that look nothing like the cause. The header now lives in
  TraccarHttp, extracted from fetchPositions so the load-bearing "read the body
  inside ZIO.scoped" constraint exists in exactly one place.
- CarDistanceService is a new trait rather than a method on CarLocationService:
  that one answers "where is the car", this one "how far did it go", and keeping
  them apart leaves the parking tests' FakeCarLocationService untouched.
- Only an answer from Traccar stamps gps_computed_at. An empty report (tracker
  dark) is an answer, so it is stamped with no distance and not retried; an
  unreachable Traccar writes nothing, so the next pass picks the reservation up.
- The "is it finished" test is on the widened window end, not end_date —
  widening pushes the window past midnight, so a booking that ended at noon
  today is not measurable until tomorrow.
- saveGpsDistance writes only the gps_* columns: no `updated_at = NOW()`, so a
  measurement never bumps the ICS LAST-MODIFIED stamp, and updateTrip's explicit
  column list means a client PUTting a Trip cannot spoof a measurement.
- The pass reads the whole trip list on purpose: clamping needs the neighbours on
  either side, including already-measured ones and ones before the cut-off.
- First background fiber in the app. catchAllCause sits inside repeat so a
  Traccar outage costs one pass, not the fiber for the process lifetime.

Tests: 8 cases on the window rule (whole-day widening, same-day midpoint split
with touching seams, the existing 00:00–23:59 midnight handover losing only 30 s,
multi-day, overlapping bookings left unclamped, no neighbour, never collapsing),
10 on the pass (cut-off, unclosed window, the widened window actually being
queried, declared value left alone, no re-measure, dark tracker stamped once,
failure retried, force), and 5 on Postgres over the new test_trip_gps schema
including that updated_at does not move and updateTrip cannot overwrite.

Verified end to end against the live tracker: two bookings sharing 2026-08-17
came out 0 km and 156 km over windows meeting at 11:00Z — the day's exact total,
with both declared values untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
carlos merged commit 277415437b into main 2026-08-18 10:04:32 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
carlos/car-sharing-back!2
No description provided.