From f84924c56ad650bf8c5f16974026f0246d1bdbca Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 12:07:25 +0200 Subject: [PATCH] refactor(trips): share the trip loading splash beats between desktop and phone Both splashes carried their own copy of the four beats, the step time, the still beat and the interval that cycles them. These now live in useTripSplashBeats; each splash keeps its markup and styling. The phone follows the reduced motion setting live while the splash is up, the desktop reads it on each render; that difference stays as the liveReducedMotion option. --- .../components/shared/TripLoadingSplash.tsx | 28 +---- .../shared/useTripSplashBeats.test.ts | 113 ++++++++++++++++++ .../components/shared/useTripSplashBeats.ts | 55 +++++++++ .../screens/trip/MTripLoadingSplash.tsx | 39 +----- 4 files changed, 176 insertions(+), 59 deletions(-) create mode 100644 client/src/components/shared/useTripSplashBeats.test.ts create mode 100644 client/src/components/shared/useTripSplashBeats.ts diff --git a/client/src/components/shared/TripLoadingSplash.tsx b/client/src/components/shared/TripLoadingSplash.tsx index 47f626432..c3bcdec9a 100644 --- a/client/src/components/shared/TripLoadingSplash.tsx +++ b/client/src/components/shared/TripLoadingSplash.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react' import { useTranslation } from '../../i18n' -import MDancingTrek, { type TrekScene } from '../../mobile/components/MDancingTrek' +import MDancingTrek from '../../mobile/components/MDancingTrek' +import { useTripSplashBeats } from './useTripSplashBeats' /** * Trip-open splash for desktop — the same little journey the mobile splash @@ -15,15 +15,6 @@ import MDancingTrek, { type TrekScene } from '../../mobile/components/MDancingTr * freezes and the mascot parks on "loading photos". Palette + glass tint swap * for light/dark via the .dark class the app puts on . */ -const STEPS: { scene: TrekScene; key: string }[] = [ - { scene: 'packing', key: 'trip.loadingSteps.pack' }, - { scene: 'transport', key: 'trip.loadingSteps.road' }, - { scene: 'dashboard', key: 'trip.loadingPhotos' }, - { scene: 'collections', key: 'trip.loadingSteps.arrive' }, -] -const STEP_MS = 1400 -const STILL_INDEX = 2 - const SPLASH_CSS = ` .m-splash-root { background: #eef0fb; } .dark .m-splash-root { background: #0c0c12; } @@ -158,18 +149,7 @@ const SPLASH_CSS = ` export default function TripLoadingSplash({ title }: { title?: string }) { const { t } = useTranslation() - const reduceMotion = - typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches - - const [index, setIndex] = useState(0) - useEffect(() => { - if (reduceMotion) return - const id = setInterval(() => setIndex((n) => (n + 1) % STEPS.length), STEP_MS) - return () => clearInterval(id) - }, [reduceMotion]) - - const activeIndex = reduceMotion ? STILL_INDEX : index - const step = STEPS[activeIndex] + const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: false }) return (
- {STEPS.map((_, i) => ( + {steps.map((_, i) => ( void; + +function stubReducedMotion(initial: boolean) { + const mq = { + matches: initial, + listeners: new Set(), + addEventListener: vi.fn((_: string, fn: Listener) => mq.listeners.add(fn)), + removeEventListener: vi.fn((_: string, fn: Listener) => mq.listeners.delete(fn)), + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn(() => mq), + }); + return { + mq, + flip(next: boolean) { + mq.matches = next; + mq.listeners.forEach((fn) => fn()); + }, + }; +} + +const originalMatchMedia = Object.getOwnPropertyDescriptor(window, 'matchMedia'); + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); + if (originalMatchMedia) Object.defineProperty(window, 'matchMedia', originalMatchMedia); + else delete (window as { matchMedia?: unknown }).matchMedia; +}); + +describe('useTripSplashBeats', () => { + it('FE-COMP-SPLASHBEATS-001: holds the four beats, 1400 ms apart, parking on the third', () => { + expect(SPLASH_STEPS.map((s) => s.scene)).toEqual(['packing', 'transport', 'dashboard', 'collections']); + expect(SPLASH_STEP_MS).toBe(1400); + expect(SPLASH_STILL_INDEX).toBe(2); + expect(SPLASH_STEPS[SPLASH_STILL_INDEX].key).toBe('trip.loadingPhotos'); + }); + + it.each([false, true])( + 'FE-COMP-SPLASHBEATS-002: steps through the beats and loops (liveReducedMotion %s)', + (liveReducedMotion) => { + stubReducedMotion(false); + const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion })); + expect(result.current.activeIndex).toBe(0); + expect(result.current.step).toBe(SPLASH_STEPS[0]); + expect(result.current.steps).toBe(SPLASH_STEPS); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS - 1)); + expect(result.current.activeIndex).toBe(0); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.activeIndex).toBe(1); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 3)); + expect(result.current.activeIndex).toBe(0); + expect(result.current.reduceMotion).toBeFalsy(); + } + ); + + it.each([false, true])( + 'FE-COMP-SPLASHBEATS-003: under reduced motion parks on the still beat (liveReducedMotion %s)', + (liveReducedMotion) => { + stubReducedMotion(true); + const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion })); + expect(result.current.reduceMotion).toBe(true); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 5)); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + expect(result.current.step).toBe(SPLASH_STEPS[SPLASH_STILL_INDEX]); + } + ); + + it('FE-COMP-SPLASHBEATS-004: live, follows the setting flipped while the splash is up and unsubscribes', () => { + const media = stubReducedMotion(false); + const { result, unmount } = renderHook(() => useTripSplashBeats({ liveReducedMotion: true })); + expect(media.mq.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + act(() => media.flip(true)); + expect(result.current.reduceMotion).toBe(true); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 2)); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + act(() => media.flip(false)); + expect(result.current.reduceMotion).toBe(false); + unmount(); + expect(media.mq.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + expect(media.mq.listeners.size).toBe(0); + }); + + it('FE-COMP-SPLASHBEATS-005: not live, never subscribes and picks up the setting on the next render', () => { + const media = stubReducedMotion(false); + const { result, rerender } = renderHook(() => useTripSplashBeats({ liveReducedMotion: false })); + expect(media.mq.addEventListener).not.toHaveBeenCalled(); + act(() => media.flip(true)); + expect(result.current.reduceMotion).toBe(false); + rerender(); + expect(result.current.reduceMotion).toBe(true); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + }); + + it('FE-COMP-SPLASHBEATS-006: live without matchMedia, plays the beats', () => { + Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: undefined }); + const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion: true })); + expect(result.current.reduceMotion).toBe(false); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS)); + expect(result.current.activeIndex).toBe(1); + }); +}); diff --git a/client/src/components/shared/useTripSplashBeats.ts b/client/src/components/shared/useTripSplashBeats.ts new file mode 100644 index 000000000..73c1c51ea --- /dev/null +++ b/client/src/components/shared/useTripSplashBeats.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; + +/** The beats of the trip-open splash: packing, hitting the road, cruising in, dropping the pin. */ +export const SPLASH_STEPS = [ + { scene: 'packing', key: 'trip.loadingSteps.pack' }, + { scene: 'transport', key: 'trip.loadingSteps.road' }, + { scene: 'dashboard', key: 'trip.loadingPhotos' }, + { scene: 'collections', key: 'trip.loadingSteps.arrive' }, +] as const; + +export const SPLASH_STEP_MS = 1400; +// Reduced motion parks on the paper-plane / "loading photos" beat. +export const SPLASH_STILL_INDEX = 2; +const REDUCE_MOTION = '(prefers-reduced-motion: reduce)'; + +interface TripSplashBeatsOptions { + /** + * Follow the OS reduced motion setting while the splash is up (the phone, whose splash can be + * up long enough for the setting to be flipped under it). Off, the setting is read on each render. + */ + liveReducedMotion: boolean; +} + +/** + * The beat cycle both trip-open splashes play: steps through SPLASH_STEPS every SPLASH_STEP_MS + * and loops; under reduced motion it holds SPLASH_STILL_INDEX instead. + */ +export function useTripSplashBeats({ liveReducedMotion }: TripSplashBeatsOptions) { + const [liveReduce, setLiveReduce] = useState(() => + liveReducedMotion ? (window.matchMedia?.(REDUCE_MOTION)?.matches ?? false) : false + ); + useEffect(() => { + if (!liveReducedMotion) return; + const mq = window.matchMedia?.(REDUCE_MOTION); + if (!mq) return; + const sync = () => setLiveReduce(mq.matches); + sync(); + mq.addEventListener('change', sync); + return () => mq.removeEventListener('change', sync); + }, [liveReducedMotion]); + + const reduceMotion = liveReducedMotion + ? liveReduce + : typeof window !== 'undefined' && window.matchMedia?.(REDUCE_MOTION).matches; + + const [index, setIndex] = useState(0); + useEffect(() => { + if (reduceMotion) return; + const id = setInterval(() => setIndex((n) => (n + 1) % SPLASH_STEPS.length), SPLASH_STEP_MS); + return () => clearInterval(id); + }, [reduceMotion]); + + const activeIndex = reduceMotion ? SPLASH_STILL_INDEX : index; + return { reduceMotion, activeIndex, step: SPLASH_STEPS[activeIndex], steps: SPLASH_STEPS }; +} diff --git a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx index 3eabc739a..6987fd021 100644 --- a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx +++ b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react' import { useTranslation } from '../../../i18n' -import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek' +import MDancingTrek from '../../components/MDancingTrek' +import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats' /** * Trip-open splash — the TREK mascot acts out a little journey while the trip @@ -10,40 +10,9 @@ import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek' * progress dots track which beat is on. Under reduced motion it holds a single * frame (the real "loading photos" one) with no transitions. */ -const STEPS: { scene: TrekScene; key: string }[] = [ - { scene: 'packing', key: 'trip.loadingSteps.pack' }, - { scene: 'transport', key: 'trip.loadingSteps.road' }, - { scene: 'dashboard', key: 'trip.loadingPhotos' }, - { scene: 'collections', key: 'trip.loadingSteps.arrive' }, -] - -const STEP_MS = 1400 -// Reduced motion parks on the paper-plane / "loading photos" beat. -const STILL_INDEX = 2 -const REDUCE_MOTION = '(prefers-reduced-motion: reduce)' - export default function MTripLoadingSplash({ title }: { title: string }) { const { t } = useTranslation() - const [reduceMotion, setReduceMotion] = useState(() => window.matchMedia?.(REDUCE_MOTION)?.matches ?? false) - // The splash can be up long enough for the OS setting to be flipped under it. - useEffect(() => { - const mq = window.matchMedia?.(REDUCE_MOTION) - if (!mq) return - const sync = () => setReduceMotion(mq.matches) - sync() - mq.addEventListener('change', sync) - return () => mq.removeEventListener('change', sync) - }, []) - - const [index, setIndex] = useState(0) - useEffect(() => { - if (reduceMotion) return - const id = setInterval(() => setIndex(n => (n + 1) % STEPS.length), STEP_MS) - return () => clearInterval(id) - }, [reduceMotion]) - - const activeIndex = reduceMotion ? STILL_INDEX : index - const step = STEPS[activeIndex] + const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true }) return (
@@ -64,7 +33,7 @@ export default function MTripLoadingSplash({ title }: { title: string }) { {/* Beat dots — the active stage widens into an accent pill. */}
- {STEPS.map((_, i) => ( + {steps.map((_, i) => (