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()