From a3a4f7cf799cbda49d7739d937a0805f6880ae04 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 12 Nov 2026 15:39:20 +0100 Subject: [PATCH] fix(mobile): days drawn ahead of a fast swipe through the day pager Only the selected day and two on either side were drawn, and the selection moves only once the strip comes to rest, which iOS takes a while for. A run of fast swipes ran past the drawn pages into blank ones until it settled. The drawn window now also follows the day the swipe is heading for, so the next days are there before they slide in. At most two windows of five pages are mounted, however long the trip. Fixes #3067 --- .../src/mobile/components/MDayPager.test.tsx | 27 +++++++++++++++++++ client/src/mobile/components/MDayPager.tsx | 12 +++++++-- client/src/mobile/components/dayPagerPages.ts | 13 ++++++--- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/client/src/mobile/components/MDayPager.test.tsx b/client/src/mobile/components/MDayPager.test.tsx index 1282e6f00..5b6df6bea 100644 --- a/client/src/mobile/components/MDayPager.test.tsx +++ b/client/src/mobile/components/MDayPager.test.tsx @@ -8,6 +8,7 @@ import { pagesFor } from './dayPagerPages'; vi.mock('../../native/haptics', () => ({ tickHaptic: vi.fn() })); const DAYS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }] as unknown as Day[]; +const LONG = Array.from({ length: 10 }, (_, k) => ({ id: k + 1 })) as unknown as Day[]; function setup(selectedDayId: number | null, over: Partial[0]> = {}) { const onSelectDay = vi.fn(); @@ -59,6 +60,19 @@ describe('pagesFor', () => { ).toEqual([4, 5, 6]); }); + it('also draws the days around the page a swipe heads for, so a run of swipes never meets blank pages', () => { + const drawn = (focusDayId: number | null) => + pagesFor(LONG, 1, focusDayId) + .filter((p) => p.near) + .map((p) => p.dayId); + expect(drawn(6)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + // Still only the selection's own window where nothing is previewed or the preview is gone. + expect(drawn(null)).toEqual([1, 2, 3]); + expect(drawn(99)).toEqual([1, 2, 3]); + // The focus never changes which page is current. + expect(pagesFor(LONG, 1, 6).find((p) => p.active)?.dayId).toBe(1); + }); + it('keeps a page for a selection that points at no day, with the first day as the way back', () => { expect(pagesFor(DAYS, null).map((p) => [p.key, p.dayId, p.active])).toEqual([ ['none', null, true], @@ -129,6 +143,19 @@ describe('MDayPager', () => { expect(onPreview).toHaveBeenLastCalledWith(null); }); + it('draws the days ahead of a swipe while the strip still moves, before the selection lands', () => { + const { strip, onSelectDay } = setup(1); + expect(screen.queryByText('page 5')).not.toBeInTheDocument(); + + place(strip, 3, true); + fireEvent.scroll(strip); + + expect(onSelectDay).not.toHaveBeenCalled(); + expect(screen.getByText('page 1 live')).toBeInTheDocument(); + expect(screen.getByText('page 5')).toBeInTheDocument(); + expect(screen.queryByText('page 6')).not.toBeInTheDocument(); + }); + it('asks once, however many events report the same landing', () => { const { strip, onSelectDay } = setup(2); diff --git a/client/src/mobile/components/MDayPager.tsx b/client/src/mobile/components/MDayPager.tsx index 166ba6107..e1b8fea36 100644 --- a/client/src/mobile/components/MDayPager.tsx +++ b/client/src/mobile/components/MDayPager.tsx @@ -16,7 +16,10 @@ import { pagesFor, type PagerItem } from './dayPagerPages'; * the swipe is decided, not once the snap has settled, which on iOS comes * a good while later. The day only changes once the strip has come to rest on * another page. Then - * the selection moves and the days around it draw their content. + * the selection moves and the days around it draw their content. The days + * around the page a swipe is heading for draw theirs as soon as it becomes the + * nearer one, so a run of quick swipes that never rests finds them drawn + * instead of blank until the snap lands (#3067). * Keys follow the day ids, so the page that slid in keeps its state (scroll * position, fetched route and weather) as it becomes the current one. * @@ -69,7 +72,10 @@ export default function MDayPager({ }: MDayPagerProps) { const stripRef = useRef(null); const [announcement, setAnnouncement] = useState(''); - const pages = pagesFor(days, selectedDayId); + /** The page a swipe last headed for. It stays after the landing, so swiping + * back over the same days finds them drawn too. */ + const [focusDayId, setFocusDayId] = useState(null); + const pages = pagesFor(days, selectedDayId, focusDayId); // Which page is current belongs in the key: on a trip of up to five days a // chip tap can keep the same pages, and the strip then stayed on the old day // while the rail already showed the new one. @@ -135,6 +141,8 @@ export default function MDayPager({ const next = dayId != null && Number.isFinite(dayId) ? dayId : null; if (next === previewed) return; previewed = next; + // Draw ahead of the finger: the days around this one, before it lands. + if (next !== null) setFocusDayId(next); if (next !== null) tickHaptic(); latest.current.onPreview?.(next); }; diff --git a/client/src/mobile/components/dayPagerPages.ts b/client/src/mobile/components/dayPagerPages.ts index 5f9d5a474..32213aa4e 100644 --- a/client/src/mobile/components/dayPagerPages.ts +++ b/client/src/mobile/components/dayPagerPages.ts @@ -11,17 +11,20 @@ interface Page { near: boolean; } -/** Pages drawn with their content either side of the selection. */ +/** Pages drawn with their content either side of the selection, and of the page a swipe heads for. */ const REACH = 2; /** * A page for every day, so quick swipes in a row never run into the end of the * strip before the first has settled (they used to spring back after two days). - * Only the selection and REACH days either side draw their content. A selection + * Only the selection and REACH days either side draw their content, and as + * many either side of `focusDayId`, the page a swipe is heading for: a run of + * quick swipes that has not landed yet then meets drawn pages instead of blank + * paper (#3067). Two windows at most, however long the trip. A selection * that points at no day (the whole-trip view, or a day deleted remotely) still * gets its page, with the first day next to it as a way back. */ -export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] { +export function pagesFor(days: PagerItem[], selectedDayId: number | null, focusDayId: number | null = null): Page[] { const i = days.findIndex((d) => d.id === selectedDayId); if (i < 0) { const first = days[0]; @@ -30,5 +33,7 @@ export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[ ...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false, near: true }] : []), ]; } - return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: Math.abs(j - i) <= REACH })); + const f = focusDayId == null ? -1 : days.findIndex((d) => d.id === focusDayId); + const near = (j: number) => Math.abs(j - i) <= REACH || (f >= 0 && Math.abs(j - f) <= REACH); + return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: near(j) })); }