feat(journey): draw the trips' GPX tracks on the journey map (#1911)

* feat(journey): serve the GPX tracks behind a journey

A journey has no trip of its own, but every entry records the trip it came from,
and a trip's routed geometries live on its places. So the tracks are derivable
without any new storage: GET /journeys/:id/tracks walks that link and returns the
geometries, dropping elevation nothing on the map reads and skipping anything
that is not a usable line.

* feat(journey): draw those tracks on the journey map

Both renderers, Leaflet and GL, plus the mobile map-timeline that shares them.
Solid and in the track's own colour with a white casing, so it reads as a
recorded route rather than as the dashed line that merely connects entries in
time order, and stays legible on satellite tiles.

* feat(journey): load the tracks alongside the journey

Fetched separately from the journey itself: most journeys have no tracks, and a
failed lookup should cost the map its lines rather than the whole page.

* docs(journey): document the GPX tracks on the map

* fix(journey): label tracks with the house map tooltip

bindTooltip's default box ignores the appearance tokens, so it sat there in
Leaflet grey next to markers already using .map-tooltip. Same class now, and an
unnamed track gets no tooltip at all instead of an empty one.
This commit is contained in:
Maurice
2026-08-12 15:14:50 +02:00
committed by GitHub
parent 767dc662b7
commit 870bd6131f
15 changed files with 334 additions and 11 deletions
@@ -2009,3 +2009,73 @@ describe('entry enrichment', () => {
expect((entry as any).source_trip_name).toBeNull();
});
});
// ── GPX tracks on the journey map (#1260) ─────────────────────────────────────
describe('journeyTracks', () => {
/** A GPX import stores the geometry on the place, as JSON [lat, lng] pairs. */
const withGeometry = (placeId: number, geometry: unknown, color: string | null = null) =>
testDb
.prepare('UPDATE places SET route_geometry = ?, route_color = ? WHERE id = ?')
.run(typeof geometry === 'string' ? geometry : JSON.stringify(geometry), color, placeId);
it('JOURNEY-SVC-TRACKS-001: returns the tracks of the trips the entries came from', () => {
const { user } = createUser(testDb);
const { trip, place } = tripWithPlace(user.id);
withGeometry(place.id, [[35.1, 135.7], [35.2, 135.8]], '#ff0000');
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
const tracks = svc.journeyTracks(journey.id, user.id)!;
expect(tracks).toHaveLength(1);
expect(tracks[0]).toMatchObject({ place_id: place.id, trip_id: trip.id, color: '#ff0000' });
expect(tracks[0].points).toEqual([[35.1, 135.7], [35.2, 135.8]]);
});
it('JOURNEY-SVC-TRACKS-002: keeps the elevation out and the pair in', () => {
const { user } = createUser(testDb);
const { trip, place } = tripWithPlace(user.id);
// The importer keeps elevation as a third value where the file had it.
withGeometry(place.id, [[47.1, 11.2, 1830], [47.2, 11.3, 1902]]);
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
expect(svc.journeyTracks(journey.id, user.id)![0].points).toEqual([[47.1, 11.2], [47.2, 11.3]]);
});
it('JOURNEY-SVC-TRACKS-003: a place without geometry contributes nothing', () => {
const { user } = createUser(testDb);
const { trip } = tripWithPlace(user.id);
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
expect(svc.journeyTracks(journey.id, user.id)).toEqual([]);
});
it('JOURNEY-SVC-TRACKS-004: unusable geometry is skipped, not fatal', () => {
const { user } = createUser(testDb);
const { trip, place } = tripWithPlace(user.id);
const second = createPlace(testDb, trip.id, { name: 'Good one' });
withGeometry(place.id, 'not json at all');
withGeometry(second.id, [[1, 2], [3, 4]]);
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
const tracks = svc.journeyTracks(journey.id, user.id)!;
expect(tracks.map(t => t.place_id)).toEqual([second.id]);
});
it('JOURNEY-SVC-TRACKS-005: a single point is a pin, not a line', () => {
const { user } = createUser(testDb);
const { trip, place } = tripWithPlace(user.id);
withGeometry(place.id, [[35.1, 135.7]]);
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
expect(svc.journeyTracks(journey.id, user.id)).toEqual([]);
});
it('JOURNEY-SVC-TRACKS-006: a stranger gets null, not another user route', () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { trip, place } = tripWithPlace(user.id);
withGeometry(place.id, [[1, 2], [3, 4]]);
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
expect(svc.journeyTracks(journey.id, stranger.id)).toBeNull();
});
});