diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 958944e75..b498c5adc 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -11,7 +11,7 @@ import { mapsPlaceEnrichmentResultSchema, type NotificationRespondRequest, type SettingUpsertRequest, type SettingsBulkRequest, - type JourneyCreateRequest, type JourneyAddTripRequest, + type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse, type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest, type JourneyShareLinkRequest, type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, @@ -887,6 +887,8 @@ export const journeyApi = { // Entries listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data), + // GPX tracks of the trips this journey's entries came from (#1260). + listTracks: (id: number): Promise => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data), createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index 7ece6edaf..61c27e5b7 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -35,14 +35,14 @@ vi.mock('leaflet', () => { map: vi.fn(() => mockMap), tileLayer: vi.fn(() => ({ addTo: vi.fn() })), marker: vi.fn(() => mockMarker), - polyline: vi.fn(() => ({ addTo: vi.fn() })), + polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }), divIcon: vi.fn(() => ({})), latLngBounds: vi.fn(() => ({})), }, map: vi.fn(() => mockMap), tileLayer: vi.fn(() => ({ addTo: vi.fn() })), marker: vi.fn(() => mockMarker), - polyline: vi.fn(() => ({ addTo: vi.fn() })), + polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }), divIcon: vi.fn(() => ({})), latLngBounds: vi.fn(() => ({})), }; @@ -489,4 +489,65 @@ describe('JourneyMap', () => { unmount(); expect(vi.mocked(map.remove).mock.calls.length).toBe(before + 1); }); + // ── GPX tracks (#1260) ────────────────────────────────────────────────────── + const track = (over: Partial<{ place_id: number; trip_id: number; name: string; color: string | null; points: [number, number][] }> = {}) => ({ + place_id: 1, trip_id: 1, name: 'Morning hike', color: '#ff0000', + points: [[47.1, 11.2], [47.2, 11.3]] as [number, number][], ...over, + }); + + it('FE-COMP-JOURNEYMAP-040: draws a track as a casing plus a coloured line', () => { + vi.mocked(L.polyline).mockClear(); + render(); + + const calls = vi.mocked(L.polyline).mock.calls; + const casing = calls.find(c => (c[1] as any)?.color === '#ffffff'); + const line = calls.find(c => (c[1] as any)?.color === '#ff0000'); + expect(casing).toBeDefined(); + expect(line).toBeDefined(); + // Leaflet wants [lat, lng], which is the order the geometry is stored in. + expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]); + // Solid, unlike the dashed line that merely connects entries in time order. + expect((line![1] as any).dashArray).toBeUndefined(); + }); + + it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => { + vi.mocked(L.polyline).mockClear(); + render(); + + const line = vi.mocked(L.polyline).mock.results + .map(r => r.value as any) + .find(v => v.bindTooltip.mock.calls.length > 0); + expect(line).toBeDefined(); + const [label, opts] = line.bindTooltip.mock.calls[0]; + expect(label).toBe('Morning hike'); + // The house tooltip, not Leaflet's default box: it reads the appearance tokens. + expect(opts.className).toBe('map-tooltip'); + }); + + it('FE-COMP-JOURNEYMAP-044: an unnamed track gets no empty tooltip', () => { + vi.mocked(L.polyline).mockClear(); + render(); + + const tooltipped = vi.mocked(L.polyline).mock.results + .map(r => r.value as any) + .filter(v => v.bindTooltip.mock.calls.length > 0); + expect(tooltipped).toHaveLength(0); + }); + + it('FE-COMP-JOURNEYMAP-041: a track without its own colour falls back rather than vanishing', () => { + vi.mocked(L.polyline).mockClear(); + render(); + + const colors = vi.mocked(L.polyline).mock.calls.map(c => (c[1] as any)?.color); + expect(colors).toContain('#4f46e5'); + }); + + it('FE-COMP-JOURNEYMAP-042: a track with fewer than two points draws nothing', () => { + vi.mocked(L.polyline).mockClear(); + render(); + + const colors = vi.mocked(L.polyline).mock.calls.map(c => (c[1] as any)?.color); + expect(colors).not.toContain('#ff0000'); + expect(colors).not.toContain('#ffffff'); + }); }); diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 5ac006cbd..d4f9f5e16 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react' import L from 'leaflet' import { useSettingsStore } from '../../store/settingsStore' +import type { JourneyTrack } from '@trek/shared' export interface MapMarkerItem { id: string @@ -35,6 +36,8 @@ interface Props { checkins: any[] entries: MapEntry[] trail?: { lat: number; lng: number }[] + /** Routed GPX geometries from the journey's trips (#1260). */ + tracks?: JourneyTrack[] height?: number dark?: boolean activeMarkerId?: string | null @@ -84,11 +87,15 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st } const EMPTY_TRAIL: { lat: number; lng: number }[] = [] +const EMPTY_TRACKS: JourneyTrack[] = [] +/** Fallback when a track carries no colour of its own, matching the planner's default. */ +const TRACK_FALLBACK_COLOR = '#4f46e5' function JourneyMap( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props, + { entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props, ) { const stableTrail = trail || EMPTY_TRAIL + const stableTracks = tracks || EMPTY_TRACKS const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url) const containerRef = useRef(null) const mapRef = useRef(null) @@ -194,6 +201,23 @@ function JourneyMap( coords.forEach(c => allCoords.push(c)) } + // GPX tracks — drawn solid and in their own colour, so they read as a recorded + // route rather than as the dashed line that merely connects entries in time order. + // A white casing keeps them legible on satellite tiles, same as the planner map. + for (const track of stableTracks) { + if (track.points.length < 2) continue + const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple) + const color = track.color || TRACK_FALLBACK_COLOR + L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }).addTo(map) + const line = L.polyline(coords, { color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }) + // Same tooltip the markers on this map use, rather than Leaflet's default box: + // it follows the appearance tokens, so it lands right in dark mode and with + // transparency switched off. + if (track.name) line.bindTooltip(track.name, { sticky: true, direction: 'top', className: 'map-tooltip' }) + line.addTo(map) + coords.forEach(c => allCoords.push(c)) + } + // route polyline — only in non-fullscreen (sidebar map) mode if (!fullScreen && items.length > 1) { const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple) @@ -255,7 +279,7 @@ function JourneyMap( mapRef.current = null markersRef.current.clear() } - }, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom]) + }, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom]) // react to activeMarkerId prop changes — runs after map is built useEffect(() => { diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 48af9b958..4f0599862 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary' import type { JourneyMapGLHandle } from './JourneyMapGL' import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy' +import type { JourneyTrack } from '@trek/shared' // Unified handle — both providers expose the same three methods. export type JourneyMapAutoHandle = JourneyMapHandle @@ -26,6 +27,7 @@ interface Props { checkins: unknown[] entries: MapEntry[] trail?: { lat: number; lng: number }[] + tracks?: JourneyTrack[] height?: number dark?: boolean activeMarkerId?: string | null diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 5524094a1..c84f6ff07 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders' +import type { JourneyTrack } from '@trek/shared' export interface JourneyMapGLHandle { highlightMarker: (id: string | null) => void @@ -27,6 +28,8 @@ interface Props { checkins: unknown[] entries: MapEntry[] trail?: { lat: number; lng: number }[] + /** Routed GPX geometries from the journey's trips (#1260). */ + tracks?: JourneyTrack[] height?: number dark?: boolean activeMarkerId?: string | null @@ -209,11 +212,15 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H } const EMPTY_TRAIL: { lat: number; lng: number }[] = [] +const EMPTY_TRACKS: JourneyTrack[] = [] +/** Fallback when a track carries no colour of its own, matching the planner's default. */ +const TRACK_FALLBACK_COLOR = '#4f46e5' function JourneyMapGL( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props, + { entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props, ) { const stableTrail = trail || EMPTY_TRAIL + const stableTracks = tracks || EMPTY_TRACKS const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') @@ -416,6 +423,43 @@ function JourneyMapGL( } } + // GPX tracks — one source for all of them, coloured per feature so a journey + // with several recorded routes keeps them apart. Casing underneath, same as the + // planner map, so a track stays readable on satellite tiles. + if (stableTracks.length > 0) { + const featureCollection: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: stableTracks + .filter(track => track.points.length > 1) + .map(track => ({ + type: 'Feature' as const, + properties: { color: track.color || TRACK_FALLBACK_COLOR, name: track.name }, + geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) }, + })), + } + if (featureCollection.features.length > 0) { + if (map.getSource('journey-tracks')) { + (map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection) + } else { + map.addSource('journey-tracks', { type: 'geojson', data: featureCollection }) + map.addLayer({ + id: 'journey-tracks-casing', + type: 'line', + source: 'journey-tracks', + paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }) + map.addLayer({ + id: 'journey-tracks-line', + type: 'line', + source: 'journey-tracks', + paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }) + } + } + } + // markers items.forEach((item) => { const el = markerHtml(item.dayColor, item.dayLabel, false) @@ -454,7 +498,7 @@ function JourneyMapGL( try { map.remove() } catch { /* noop */ } mapRef.current = null } - }, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom]) + }, [entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom]) // Switching the UI language has to repin the basemap labels without tearing // the map down. The load handler covers the initial run. diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index d3c4dd36c..d7d43378d 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -5,6 +5,7 @@ import MobileEntryCard from './MobileEntryCard' import type { JourneyMapHandle } from './JourneyMap' import type { JourneyEntry } from '../../store/journeyStore' import { DAY_COLORS } from './dayColors' +import type { JourneyTrack } from '@trek/shared' interface MapEntry { id: string @@ -19,6 +20,7 @@ interface Props { entries: JourneyEntry[] | any[] mapEntries: MapEntry[] trail?: { lat: number; lng: number }[] + tracks?: JourneyTrack[] dark?: boolean readOnly?: boolean onEntryClick: (entry: any) => void @@ -31,6 +33,7 @@ export default function MobileMapTimeline({ entries, mapEntries, trail, + tracks, dark, readOnly, onEntryClick, @@ -160,6 +163,7 @@ export default function MobileMapTimeline({ entries={mapEntries} checkins={[]} trail={trail} + tracks={tracks} height={9999} dark={dark} onMarkerClick={handleMarkerClick} @@ -190,6 +194,7 @@ export default function MobileMapTimeline({ entries={mapEntries} checkins={[]} trail={trail} + tracks={tracks} height={9999} dark={dark} activeMarkerId={activeEntryId} diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index d280967c6..4f4683b6a 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -38,7 +38,7 @@ export default function MJourneyDetail() { showInvite, setShowInvite, showSettings, setShowSettings, hideSkeletons, - sidebarMapItems, + sidebarMapItems, tracks, loadJourney, updateEntry, deleteEntry, uploadPhotos, } = useJourneyDetail() @@ -209,6 +209,7 @@ export default function MJourneyDetail() { ref={mapRef} checkins={[]} entries={sidebarMapItems} + tracks={tracks} height={9999} dark={dark} activeMarkerId={entries[activeIndex] ? String(entries[activeIndex].id) : null} diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index aa03cc08c..698d5ee5a 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -44,7 +44,7 @@ function JourneyDetailPageDesktop() { unlinkTrip, setUnlinkTrip, showSettings, setShowSettings, hideSkeletons, setHideSkeletons, mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick, - mapEntries, sidebarMapItems, tripDates, isMobile, + mapEntries, sidebarMapItems, tripDates, isMobile, tracks, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto, } = useJourneyDetail() @@ -86,6 +86,7 @@ function JourneyDetailPageDesktop() { setViewingEntry(entry)} @@ -441,6 +442,7 @@ function JourneyDetailPageDesktop() { ref={mapRef} checkins={[]} entries={sidebarMapItems as any} + tracks={tracks} height={9999} activeMarkerId={activeEntryId} onMarkerClick={handleMarkerClick} diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index 68bf4e822..b83039806 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -3,6 +3,8 @@ import { useParams, useNavigate, useSearchParams } from 'react-router' import { useJourneyStore } from '../../store/journeyStore' import { useTranslation } from '../../i18n' import { addListener, removeListener } from '../../api/websocket' +import { journeyApi } from '../../api/client' +import type { JourneyTrack } from '@trek/shared' import { DAY_COLORS } from '../../components/Journey/dayColors' import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/Journey/JourneyMapAuto' import { useToast } from '../../components/shared/Toast' @@ -61,6 +63,19 @@ export function useJourneyDetail() { if (id) loadJourney(Number(id)).catch(() => {}) }, [id]) + // GPX tracks of the trips behind this journey (#1260). Loaded separately from the + // journey itself: a journey without tracks is the common case, and a failed lookup + // should cost the map its lines, not the whole page. + const [tracks, setTracks] = useState([]) + useEffect(() => { + if (!id) return + let cancelled = false + journeyApi.listTracks(Number(id)) + .then(res => { if (!cancelled) setTracks(res.tracks ?? []) }) + .catch(() => { if (!cancelled) setTracks([]) }) + return () => { cancelled = true } + }, [id]) + useEffect(() => { if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons) }, [current?.hide_skeletons]) @@ -287,7 +302,7 @@ export function useJourneyDetail() { unlinkTrip, setUnlinkTrip, showSettings, setShowSettings, hideSkeletons, setHideSkeletons, mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick, - mapEntries, sidebarMapItems, tripDates, isMobile, + mapEntries, sidebarMapItems, tripDates, isMobile, tracks, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto, } } diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index b9fbe2150..5658ca2b1 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -3,7 +3,7 @@ import { avatarUrl } from '../common/avatarUrl'; import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types'; import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; -import type { TrekWsUserEventName } from '@trek/shared'; +import type { JourneyTrack, TrekWsUserEventName } from '@trek/shared'; import { TrekPhotosRepository } from '../photos/trek-photos.repository'; @@ -771,6 +771,63 @@ export class JourneyDomainService { // ── Entries ────────────────────────────────────────────────────────────── + /** + * The GPX tracks belonging to a journey (#1260). A journey has no trip of its own, + * so the link runs through its entries: every entry records the trip it came from, + * and a trip's routed geometries live on its places. Uploading a GPX in the planner + * therefore already puts everything in place; nothing drew it in the journal. + * + * Only places that actually carry geometry are returned, so a journey whose trips + * have no tracks answers with an empty list rather than a wall of null points. + */ + journeyTracks(journeyId: number, userId: number): JourneyTrack[] | null { + if (!this.canAccessJourney(journeyId, userId)) return null; + + const rows = this.db.prepare(` + SELECT DISTINCT p.id AS place_id, p.trip_id, p.name, p.route_color, p.route_geometry + FROM journey_entries je + JOIN places p ON p.trip_id = je.source_trip_id + WHERE je.journey_id = ? + AND je.source_trip_id IS NOT NULL + AND p.route_geometry IS NOT NULL + ORDER BY p.trip_id, p.id + `).all(journeyId) as { + place_id: number; trip_id: number; name: string | null; + route_color: string | null; route_geometry: string; + }[]; + + const tracks: JourneyTrack[] = []; + for (const row of rows) { + let parsed: unknown; + try { + parsed = JSON.parse(row.route_geometry); + } catch { + continue; // A geometry that is not JSON is not worth failing the whole map over. + } + if (!Array.isArray(parsed)) continue; + + const points: [number, number][] = []; + for (const entry of parsed) { + if (!Array.isArray(entry) || entry.length < 2) continue; + const lat = Number(entry[0]); + const lng = Number(entry[1]); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; + points.push([lat, lng]); + } + // A single point is a pin, not a line, and the map already has the entry markers. + if (points.length < 2) continue; + + tracks.push({ + place_id: row.place_id, + trip_id: row.trip_id, + name: row.name ?? '', + color: row.route_color, + points, + }); + } + return tracks; + } + listEntries(journeyId: number, userId: number) { if (!this.canAccessJourney(journeyId, userId)) return null; diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 200ab0352..b14c35169 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -421,6 +421,20 @@ export class JourneyController { return { entries }; } + /** + * The GPX tracks drawn on this journey's map (#1260). Read-only and derived: the + * geometries belong to the trips the entries came from, so uploading a GPX in the + * planner is all it takes for it to show up here. + */ + @Get(':id/tracks') + listTracks(@CurrentUser() user: User, @Param('id') id: string) { + const tracks = this.journey.journeyTracks(Number(id), user.id); + if (!tracks) { + throw new HttpException({ error: 'Journey not found' }, 404); + } + return { tracks }; + } + @Post(':id/entries') createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) { if (!body.entry_date) { diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 7398abcdc..03dd52595 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -51,6 +51,7 @@ export class JourneyService { // Entries listEntries(id: number, userId: number) { return this.journey.listEntries(id, userId); } + journeyTracks(id: number, userId: number) { return this.journey.journeyTracks(id, userId); } // Entry create/update bodies are free-form in the legacy route (req.body: any); // the cast keeps that boundary here so callers needn't pre-shape the payload. createEntry(id: number, userId: number, data: Record, sid?: string) { return this.journey.createEntry(id, userId, data as Parameters[2], sid); } diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index e98cebc90..9fedb93fb 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -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(); + }); +}); diff --git a/shared/src/journey/journey.schema.ts b/shared/src/journey/journey.schema.ts index a8e2fd1b1..fe41a134c 100644 --- a/shared/src/journey/journey.schema.ts +++ b/shared/src/journey/journey.schema.ts @@ -130,3 +130,24 @@ export const journeyShareLinkRequestSchema = z.looseObject({ share_map: z.unknown().optional(), }); export type JourneyShareLinkRequest = z.infer; + +/** + * GPX tracks a journey can draw (#1260). A journey has no trip of its own, but its + * entries record the trip and place they came from, so the tracks are the routed + * geometries of the places in those trips. Points are [lat, lng] pairs, matching the + * order the GPX importer stored them in; elevation, where the import kept it, is + * dropped here because nothing on the map reads it. + */ +export const journeyTrackSchema = z.object({ + place_id: z.number(), + trip_id: z.number(), + name: z.string(), + color: z.string().nullable(), + points: z.array(z.tuple([z.number(), z.number()])), +}); +export type JourneyTrack = z.infer; + +export const journeyTracksResponseSchema = z.object({ + tracks: z.array(journeyTrackSchema), +}); +export type JourneyTracksResponse = z.infer; diff --git a/wiki/Journey-Journal.md b/wiki/Journey-Journal.md index 768273b31..50aa16c5a 100644 --- a/wiki/Journey-Journal.md +++ b/wiki/Journey-Journal.md @@ -55,6 +55,10 @@ On mobile, entries are displayed in a horizontal scrolling timeline of card thum The journey detail page includes a map on the right (desktop) or an integrated map-timeline (mobile) showing all entry locations alongside the places from linked trips. +**GPX tracks are drawn too.** Any route you imported into one of the journey's linked trips, from a `.gpx`, `.kml` or `.kmz` file, appears on the journey map as well, in the same colour it has in the trip planner and with a white casing so it stays readable on satellite tiles. Nothing to switch on: the tracks belong to the trips your entries came from, so importing the file while planning is all it takes. Hovering a track shows its name. + +The thin dashed line connecting entries in date order is something else and stays as it is: that one is drawn by TREK, while a track is the route you actually recorded. + ![Journey detail page for "Autumn in Japan" with its cover header and day/place/entry/photo counts, the day-by-day timeline with Add Entry actions on the left, and the entry map on the right](assets/JourneyDetail.png) ## Plugin entry rows