diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx
index 518e53908..5178f2909 100644
--- a/client/src/components/Journey/JourneyMap.test.tsx
+++ b/client/src/components/Journey/JourneyMap.test.tsx
@@ -1,4 +1,4 @@
-// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-027
+// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-049
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -604,6 +604,39 @@ describe('JourneyMap', () => {
expect(colors).not.toContain('#ffffff');
});
+ /** The opening viewport is the assertion here, so the frame gets read out of the bounds call. */
+ const fitCoords = () => vi.mocked(L.latLngBounds).mock.calls[0][0] as [number, number][];
+
+ // #2194: a recorded drive across the country used to pull the opening view out to
+ // the whole trip, leaving the journey's own entries as a speck.
+ it('FE-COMP-JOURNEYMAP-048: frames the entries, not the tracks, when the journey has both', () => {
+ const origRAF = globalThis.requestAnimationFrame;
+ globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
+ try {
+ render(
+
+ );
+
+ expect(mockedMap().fitBounds).toHaveBeenCalled();
+ expect(fitCoords()).toEqual([[48.8566, 2.3522], [52.52, 13.405]]);
+ } finally {
+ globalThis.requestAnimationFrame = origRAF;
+ }
+ });
+
+ it('FE-COMP-JOURNEYMAP-049: with nothing else on the map the tracks are framed instead of the world', () => {
+ const origRAF = globalThis.requestAnimationFrame;
+ globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
+ try {
+ render();
+
+ expect(fitCoords()).toEqual([[47.1, 11.2], [47.2, 11.3]]);
+ expect(mockedMap().setView).not.toHaveBeenCalledWith([30, 0], 2);
+ } finally {
+ globalThis.requestAnimationFrame = origRAF;
+ }
+ });
+
// A string handed to bindTooltip becomes innerHTML. A track name is a place
// name off a shared trip and an entry label is a journey title, so both are
// written by someone who is not necessarily the person reading the map — and
diff --git a/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx b/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx
index 5d0ba1279..27c04f8fc 100644
--- a/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx
+++ b/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import {
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
- List, Grid3x3, MapPin, Archive, ArchiveRestore,
+ List, Grid3x3, MapPin, Route, Archive, ArchiveRestore,
} from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
@@ -62,6 +62,7 @@ export default function MJourneySettingsSheet({
const [availableTrips, setAvailableTrips] = useState([])
const [linkingTripId, setLinkingTripId] = useState(null)
const [shareLink, setShareLink] = useState(null)
+ const [savingTracks, setSavingTracks] = useState(false)
const [copied, setCopied] = useState(false)
const coverRef = useRef(null)
@@ -98,6 +99,22 @@ export default function MJourneySettingsSheet({
}
}
+ // Written on the spot rather than on Save, like the archive button: it is a view
+ // setting and the point of it is watching the map change (#2194). onRefresh, not
+ // onSaved, because onSaved closes the sheet the instant the switch is flipped and
+ // would drop a title the owner has typed but not saved yet.
+ const handleTracksToggle = async (next: boolean) => {
+ setSavingTracks(true)
+ try {
+ await updateJourney(journey.id, { show_trip_tracks: next })
+ onRefresh()
+ } catch {
+ toast.error(t('journey.settings.saveFailed'))
+ } finally {
+ setSavingTracks(false)
+ }
+ }
+
const openAddTrip = async () => {
setAddTripOpen(v => !v)
if (availableTrips.length === 0) {
@@ -236,6 +253,22 @@ export default function MJourneySettingsSheet({
className={`${inputShell} text-[0.8125rem] font-medium`}
/>
+ {/* Trip GPX tracks on the journey map (#2194) */}
+
{t('journey.settings.tracks')}
+
+
+
+
{t('journey.settings.showTripTracks')}
+
{t('journey.settings.showTripTracksHint')}
+
+
+
+
{/* Synced trips */}
{t('journey.detail.syncedTrips')}
{journey.trips.map(trip => (
diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx
index 6c63a28f2..eec0a2f9a 100644
--- a/client/src/pages/JourneyDetailPage.tsx
+++ b/client/src/pages/JourneyDetailPage.tsx
@@ -89,6 +89,33 @@ function JourneyDetailPageDesktop() {
const skeletonLabel = hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')
const barButton = 'w-10 h-10 flex-shrink-0 rounded-lg bg-surface-elevated backdrop-blur-lg border border-edge shadow-lg text-content-secondary flex items-center justify-center hover:bg-surface-hover active:scale-95 transition-transform'
+ // Adding photos hangs off the controls row, which is hidden below 1024px, so
+ // the gallery takes the actions over there instead. Exactly one host renders
+ // them, never both.
+ const galleryActions = canEditEntries && view === 'gallery' ? (
+