From 0ef3cbf520be4b5877d2fde10fe7f5985cc484ac Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 10:33:49 +0100 Subject: [PATCH] feat(mobile): a trip opens without its loading screen on the phone and the tablet The touch shell waited for the desktop's minimum splash of a second and a half after every trip load. It no longer does: while the trip loads it shows only its background, marked as a route fallback, so the page transition holds the dashboard until the trip is there and the trip slides in whole, as the dashboard slides back on the way out. The splash only comes up when a load takes longer than a second. The trip opened before, still in the store for a frame, is never shown for the one opening. --- .../screens/trip/MTripLoadingSplash.tsx | 31 ++++++++++++ client/src/mobile/screens/trip/MTripShell.tsx | 16 +++--- .../unit/mobile/trip/MTripShell.test.tsx | 50 +++++++++++++------ 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx index 6987fd021..ca0c4b286 100644 --- a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx +++ b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react' import { useTranslation } from '../../../i18n' import MDancingTrek from '../../components/MDancingTrek' import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats' @@ -10,6 +11,36 @@ import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeat * progress dots track which beat is on. Under reduced motion it holds a single * frame (the real "loading photos" one) with no transitions. */ +/** + * How long a trip may take to open before the splash comes up. The page + * transition holds the page the trip was opened from for up to 800ms + * (mobile/routeTransition.ts), so a trip that loads in that time slides in + * whole, the way the dashboard slides back in on the way out, and the splash is + * only for a slow connection. + */ +export const TRIP_SPLASH_AFTER_MS = 1000 + +/** + * While a trip opens. At first nothing but the screen's own background, marked + * as a route fallback, which the page transition waits on; the splash only once + * the load takes longer than TRIP_SPLASH_AFTER_MS. + */ +export function MTripOpening({ title }: { title: string }) { + const [slow, setSlow] = useState(false) + useEffect(() => { + const timer = window.setTimeout(() => setSlow(true), TRIP_SPLASH_AFTER_MS) + return () => window.clearTimeout(timer) + }, []) + if (slow) return + return ( +
+ ) +} + export default function MTripLoadingSplash({ title }: { title: string }) { const { t } = useTranslation() const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true }) diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index d8b7dfe1d..99c5ce573 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -16,7 +16,7 @@ import MPlacesBrowser from './places/MPlacesBrowser' import MTripTabPanel from './tabs/MTripTabPanel' import MTripSheets from './sheets/MTripSheets' import MReceiptScanButton from './tabs/MReceiptScanButton' -import MTripLoadingSplash from './MTripLoadingSplash' +import { MTripOpening } from './MTripLoadingSplash' import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule' import { stageOf } from '../../../components/Roadtrip/roadtripRowModel' import { badgeLabel, distanceBadge } from './roadtrip/stageBadges' @@ -450,9 +450,11 @@ export default function MTripShell({ // With the plan beside the map, the map shows the day the plan shows: its // places framed, its route drawn, the other days' pins put away, exactly as // a phone does when it switches over to the map. - // Not before the splash has gone: the map is not there to frame until then. - const mapReady = !planner.isLoading && planner.splashDone - const followDayId = mapReady && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null + // Not before the trip has opened: the map is not there to frame until then. + // The store can still hold the trip opened before, for the frame before loadTrip resets it. + const ownTrip = trip && String(trip.id) === String(tripId) ? trip : null + const opened = !planner.isLoading && !!ownTrip + const followDayId = opened && split && trTab === 'plan' && view === 'plan' && mode !== 'browse' ? planner.selectedDayId : null const { autoShowRoute, setExpandedDayIds } = planner const selectDayRef = useRef(planner.handleSelectDay) useEffect(() => { @@ -521,9 +523,9 @@ export default function MTripShell({ openDocSyncSignal, } - // Splash — same gate as the desktop page, in the mobile design language. - if (planner.isLoading || !planner.splashDone) { - return + // No minimum splash as on the desktop: the page transition hides the wait (MTripOpening). + if (!opened && (planner.isLoading || trip)) { + return } if (!trip) return null diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx index 9d2b9e59a..4e8e61649 100644 --- a/client/tests/unit/mobile/trip/MTripShell.test.tsx +++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx @@ -27,6 +27,7 @@ vi.mock('../../../../src/mobile/screens/trip/tabs/MTripTabPanel', () => ({ defau vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSheets', () => ({ default: () => null })) import MTripShell from '../../../../src/mobile/screens/trip/MTripShell' +import { TRIP_SPLASH_AFTER_MS } from '../../../../src/mobile/screens/trip/MTripLoadingSplash' /** Last shell api handed to a slot — the object every slot shares. */ let shellApi!: MTripShellApi @@ -135,15 +136,30 @@ describe('MTripShell', () => { ) }) - it('FE-MOB-SHELL-001: shows the loading splash with the trip title while the planner loads', () => { - renderShell({ isLoading: true } as Partial) - expect(screen.getByText('Japan 2026')).toBeInTheDocument() - expect(screen.queryByTestId('sheets')).not.toBeInTheDocument() + it('FE-MOB-SHELL-001: while the trip loads the page transition holds, and only a slow load brings the splash', () => { + vi.useFakeTimers() + try { + const { container } = renderShell({ isLoading: true } as Partial) + expect(container.querySelector('[data-route-fallback]')).toBeInTheDocument() + expect(screen.queryByText('Japan 2026')).not.toBeInTheDocument() + expect(screen.queryByTestId('sheets')).not.toBeInTheDocument() + + act(() => vi.advanceTimersByTime(TRIP_SPLASH_AFTER_MS)) + expect(screen.getByText('Japan 2026')).toBeInTheDocument() + expect(container.querySelector('[data-route-fallback]')).not.toBeInTheDocument() + } finally { + vi.useRealTimers() + } }) - it('FE-MOB-SHELL-002: keeps the splash up until the photo warm-up finished', () => { + it('FE-MOB-SHELL-002: the trip shows as soon as it has loaded, without the desktop splash minimum', () => { renderShell({ splashDone: false } as Partial) - expect(screen.getByText('Japan 2026')).toBeInTheDocument() + expect(screen.getByTestId('map-area')).toBeInTheDocument() + }) + + it('FE-MOB-SHELL-002b: the trip opened before, still in the store, is never shown for the one opening', () => { + const { container } = renderShell({ tripId: 2 } as Partial) + expect(container.querySelector('[data-route-fallback]')).toBeInTheDocument() expect(screen.queryByTestId('map-area')).not.toBeInTheDocument() }) @@ -153,8 +169,14 @@ describe('MTripShell', () => { }) it('FE-MOB-SHELL-003b: the splash falls back to the brand name before the trip arrives', () => { - renderShell({ isLoading: true, trip: null } as Partial) - expect(screen.getByText('TREK')).toBeInTheDocument() + vi.useFakeTimers() + try { + renderShell({ isLoading: true, trip: null } as Partial) + act(() => vi.advanceTimersByTime(TRIP_SPLASH_AFTER_MS)) + expect(screen.getByText('TREK')).toBeInTheDocument() + } finally { + vi.useRealTimers() + } }) it('FE-MOB-SHELL-004: seeds the running trip on today rather than on day one', () => { @@ -617,27 +639,27 @@ describe('MTripShell', () => { // #2880: the day is seeded while the splash still covers the shell, so the // rail only exists after the selection has settled. - it('FE-MOB-SHELL-094: the active day is brought into view once the splash lifts (#2880)', () => { + it('FE-MOB-SHELL-094: the active day is brought into view once the trip has opened (#2880)', () => { const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView) scrollIntoView.mockClear() stubRects({ left: 480, right: 540 }) - const { planner, rerenderShell } = renderShell({ selectedDayId: 12, splashDone: false } as Partial) + const { planner, rerenderShell } = renderShell({ selectedDayId: 12, isLoading: true } as Partial) expect(scrollIntoView).not.toHaveBeenCalled() - ;(planner as { splashDone: boolean }).splashDone = true + ;(planner as { isLoading: boolean }).isLoading = false rerenderShell() expect(scrollIntoView).toHaveBeenCalledTimes(1) expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', inline: 'center', block: 'nearest' }) }) - it('FE-MOB-SHELL-095: after the splash a chip already in view leaves the rail alone', () => { + it('FE-MOB-SHELL-095: once the trip has opened a chip already in view leaves the rail alone', () => { const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView) scrollIntoView.mockClear() stubRects({ left: 40, right: 100 }) - const { planner, rerenderShell } = renderShell({ selectedDayId: 12, splashDone: false } as Partial) + const { planner, rerenderShell } = renderShell({ selectedDayId: 12, isLoading: true } as Partial) - ;(planner as { splashDone: boolean }).splashDone = true + ;(planner as { isLoading: boolean }).isLoading = false rerenderShell() expect(scrollIntoView).not.toHaveBeenCalled()