Show the GPS-measured distance under the declared one, with a button to measure #1

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

The backend now measures each reservation against the car's tracker and stores
the result beside the declared figure, but none of it was visible: the Dashboard
showed only the editable declared value, so the whole point — seeing that a
reservation declared at 100 km actually covered 156 — was invisible.

The Distance cell now stacks the editable declared value over a read-only line
carrying a satellite icon and the measured kilometres. Hovering it explains where
the number comes from, including the window actually measured, which is not the
declared start/end: it is widened to whole days and split with the neighbouring
reservation, so an early pickup and a late return count. That is the answer to
"pourquoi ma réservation de midi affiche 200 km ?", and it needs saying next to
the number rather than in a commit message.

Nothing renders before a pass has run — gpsComputedAt is the marker — so the 22
reservations that predate the cut-off stay as they were. Once a pass has run, a
missing distance means the tracker stayed dark for the window, which shows as
"GPS muet" rather than being passed off as "not measured".

A discreet outline button next to "Nouveau trajet" runs a pass on demand instead
of waiting for the twelve-hourly timer. force is deliberately not exposed: the
button only measures what is missing, so nobody can rewrite every measurement by
accident.

Design notes:

  • NullableOptionalOffsetDateTime, rather than NullableOptional(FormattedOffset-
    DateTime). The latter empties the ENTIRE reservation list: it pipes its
    transform onto Sc.NullOr(schema) and then targets Sc.UndefinedOr(schema), so it
    hands the target the decoded value where the encoded one is expected. That is
    fine for every existing use — plain primitives and structs, where both sides
    coincide — and fails with "Expected string | undefined" the moment the inner
    schema is itself a transformation, whereupon getAllTrips' decode fallback
    swallows it into []. The string ↔ Date conversion therefore happens inside one
    transform, the way NotANumberToOptionalLocalDate does it.
  • catchUnauthorizedStrict, not catchUnauthorized: the endpoint answers 400 when
    Traccar is unreachable, and catchUnauthorized maps 400 to NotAuthenticated,
    which Remix turns into a redirect to /login — logging the user out because a
    homelab box was down.
  • A scoped useFetcher rather than useSubmit/useNavigation, so measuring never
    disturbs actionData or the pending state that the trip form and the distance
    calculator both read. The POST still revalidates the loader, so the table picks
    up the new values without a manual refresh.
  • The gps_* fields ride along on every inline cell edit, since TripUpdate is also
    the PUT body and updateData spreads the whole row. That is safe: the backend's
    update statement lists its columns and cannot touch them.
  • data-table.tsx capped the distance cell at max-w-8, i.e. 2 rem, the narrowest of
    its three branches. Two stacked lines inside 32 px wrap character by character.
  • NO_TRIPS gives "no reservations" a stable identity. useTripTable mirrors its
    argument into state through useEffect(..., [loaderTrips]), so the fresh [] the
    route built on every render sent it into an unbounded update loop whenever the
    loader failed — after a mutation revalidated against a backend that had gone
    away, for instance — taking the page down rather than showing the error. The
    existing edit-and-blur path reaches it too; the new button is one more way in.

Verified in a browser against both halves running locally: 156 km and 0 km appear
under a declared 100 and a blank, seed rows show no GPS line, the button reports
"2 réservation(s) mesurée(s)" and then "Rien à mesurer", editing a declared value
leaves the measurement intact, the line does not wrap at 390 px, and with Traccar
unreachable the red banner appears while the session survives.

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

The backend now measures each reservation against the car's tracker and stores the result beside the declared figure, but none of it was visible: the Dashboard showed only the editable declared value, so the whole point — seeing that a reservation declared at 100 km actually covered 156 — was invisible. The Distance cell now stacks the editable declared value over a read-only line carrying a satellite icon and the measured kilometres. Hovering it explains where the number comes from, including the window actually measured, which is not the declared start/end: it is widened to whole days and split with the neighbouring reservation, so an early pickup and a late return count. That is the answer to "pourquoi ma réservation de midi affiche 200 km ?", and it needs saying next to the number rather than in a commit message. Nothing renders before a pass has run — gpsComputedAt is the marker — so the 22 reservations that predate the cut-off stay as they were. Once a pass has run, a missing distance means the tracker stayed dark for the window, which shows as "GPS muet" rather than being passed off as "not measured". A discreet outline button next to "Nouveau trajet" runs a pass on demand instead of waiting for the twelve-hourly timer. `force` is deliberately not exposed: the button only measures what is missing, so nobody can rewrite every measurement by accident. Design notes: - NullableOptionalOffsetDateTime, rather than NullableOptional(FormattedOffset- DateTime). The latter empties the ENTIRE reservation list: it pipes its transform onto Sc.NullOr(schema) and then targets Sc.UndefinedOr(schema), so it hands the target the decoded value where the encoded one is expected. That is fine for every existing use — plain primitives and structs, where both sides coincide — and fails with "Expected string | undefined" the moment the inner schema is itself a transformation, whereupon getAllTrips' decode fallback swallows it into []. The string ↔ Date conversion therefore happens inside one transform, the way NotANumberToOptionalLocalDate does it. - catchUnauthorizedStrict, not catchUnauthorized: the endpoint answers 400 when Traccar is unreachable, and catchUnauthorized maps 400 to NotAuthenticated, which Remix turns into a redirect to /login — logging the user out because a homelab box was down. - A scoped useFetcher rather than useSubmit/useNavigation, so measuring never disturbs actionData or the pending state that the trip form and the distance calculator both read. The POST still revalidates the loader, so the table picks up the new values without a manual refresh. - The gps_* fields ride along on every inline cell edit, since TripUpdate is also the PUT body and updateData spreads the whole row. That is safe: the backend's update statement lists its columns and cannot touch them. - data-table.tsx capped the distance cell at max-w-8, i.e. 2 rem, the narrowest of its three branches. Two stacked lines inside 32 px wrap character by character. - NO_TRIPS gives "no reservations" a stable identity. useTripTable mirrors its argument into state through useEffect(..., [loaderTrips]), so the fresh [] the route built on every render sent it into an unbounded update loop whenever the loader failed — after a mutation revalidated against a backend that had gone away, for instance — taking the page down rather than showing the error. The existing edit-and-blur path reaches it too; the new button is one more way in. Verified in a browser against both halves running locally: 156 km and 0 km appear under a declared 100 and a blank, seed rows show no GPS line, the button reports "2 réservation(s) mesurée(s)" and then "Rien à mesurer", editing a declared value leaves the measurement intact, the line does not wrap at 390 px, and with Traccar unreachable the red banner appears while the session survives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Show the GPS-measured distance under the declared one, with a button to measure
Some checks were skipped
CI Pipeline / Deploy to Coolify (pull_request) Has been skipped
9a46827dde
The backend now measures each reservation against the car's tracker and stores
the result beside the declared figure, but none of it was visible: the Dashboard
showed only the editable declared value, so the whole point — seeing that a
reservation declared at 100 km actually covered 156 — was invisible.

The Distance cell now stacks the editable declared value over a read-only line
carrying a satellite icon and the measured kilometres. Hovering it explains where
the number comes from, including the window actually measured, which is not the
declared start/end: it is widened to whole days and split with the neighbouring
reservation, so an early pickup and a late return count. That is the answer to
"pourquoi ma réservation de midi affiche 200 km ?", and it needs saying next to
the number rather than in a commit message.

Nothing renders before a pass has run — gpsComputedAt is the marker — so the 22
reservations that predate the cut-off stay as they were. Once a pass has run, a
missing distance means the tracker stayed dark for the window, which shows as
"GPS muet" rather than being passed off as "not measured".

A discreet outline button next to "Nouveau trajet" runs a pass on demand instead
of waiting for the twelve-hourly timer. `force` is deliberately not exposed: the
button only measures what is missing, so nobody can rewrite every measurement by
accident.

Design notes:
- NullableOptionalOffsetDateTime, rather than NullableOptional(FormattedOffset-
  DateTime). The latter empties the ENTIRE reservation list: it pipes its
  transform onto Sc.NullOr(schema) and then targets Sc.UndefinedOr(schema), so it
  hands the target the decoded value where the encoded one is expected. That is
  fine for every existing use — plain primitives and structs, where both sides
  coincide — and fails with "Expected string | undefined" the moment the inner
  schema is itself a transformation, whereupon getAllTrips' decode fallback
  swallows it into []. The string ↔ Date conversion therefore happens inside one
  transform, the way NotANumberToOptionalLocalDate does it.
- catchUnauthorizedStrict, not catchUnauthorized: the endpoint answers 400 when
  Traccar is unreachable, and catchUnauthorized maps 400 to NotAuthenticated,
  which Remix turns into a redirect to /login — logging the user out because a
  homelab box was down.
- A scoped useFetcher rather than useSubmit/useNavigation, so measuring never
  disturbs actionData or the pending state that the trip form and the distance
  calculator both read. The POST still revalidates the loader, so the table picks
  up the new values without a manual refresh.
- The gps_* fields ride along on every inline cell edit, since TripUpdate is also
  the PUT body and updateData spreads the whole row. That is safe: the backend's
  update statement lists its columns and cannot touch them.
- data-table.tsx capped the distance cell at max-w-8, i.e. 2 rem, the narrowest of
  its three branches. Two stacked lines inside 32 px wrap character by character.
- NO_TRIPS gives "no reservations" a stable identity. useTripTable mirrors its
  argument into state through useEffect(..., [loaderTrips]), so the fresh [] the
  route built on every render sent it into an unbounded update loop whenever the
  loader failed — after a mutation revalidated against a backend that had gone
  away, for instance — taking the page down rather than showing the error. The
  existing edit-and-blur path reaches it too; the new button is one more way in.

Verified in a browser against both halves running locally: 156 km and 0 km appear
under a declared 100 and a blank, seed rows show no GPS line, the button reports
"2 réservation(s) mesurée(s)" and then "Rien à mesurer", editing a declared value
leaves the measurement intact, the line does not wrap at 390 px, and with Traccar
unreachable the red banner appears while the session survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
carlos merged commit 4d218ae939 into main 2026-08-18 10:05:07 +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-front!1
No description provided.