diff --git a/client/e2e/mobile-day-swipe-2051.spec.ts b/client/e2e/mobile-day-swipe-2051.spec.ts
new file mode 100644
index 000000000..9d3999c4b
--- /dev/null
+++ b/client/e2e/mobile-day-swipe-2051.spec.ts
@@ -0,0 +1,98 @@
+import { test, expect, devices } from '@playwright/test'
+import type { Page } from '@playwright/test'
+import { dismissSystemNotices } from './helpers'
+
+// Phone coverage for #2051: the day details area steps days under a horizontal
+// swipe, so the trip is navigable one-handed instead of only from the chip rail
+// pinned to the top of the screen.
+//
+// jsdom can drive the handlers but not the browser around them: whether the
+// gesture survives real touch dispatch, whether the browser hands the vertical
+// axis to its own scroller first, and whether a swipe at the first day stays put
+// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the
+// events are the ones a finger produces — not synthesised TouchEvent objects.
+//
+// Chromium, because CDP is where real touch injection lives. The file-level
+// device sets a phone viewport with a coarse pointer, which is what puts the
+// mobile shell on screen at all.
+test.use({ ...devices['Pixel 7'] })
+
+/** One finger, dispatched through the browser's own input pipeline. */
+async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) {
+ const cdp = await page.context().newCDPSession(page)
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
+ for (let i = 1; i <= steps; i++) {
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [{
+ x: from.x + ((to.x - from.x) * i) / steps,
+ y: from.y + ((to.y - from.y) * i) / steps,
+ }],
+ })
+ }
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
+ await cdp.detach()
+ // The out leg is 150ms and the in leg 220ms.
+ await page.waitForTimeout(500)
+}
+
+const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
+
+test('#2051 phone: the day details area swipes between days', async ({ page }) => {
+ await page.goto('/dashboard')
+ await dismissSystemNotices(page)
+
+ // Without a phone viewport and a coarse pointer the mobile shell never mounts,
+ // and everything below would be proving nothing.
+ const env = await page.evaluate(() => ({
+ width: window.innerWidth,
+ coarse: window.matchMedia('(pointer: coarse)').matches,
+ }))
+ expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768)
+ expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true)
+
+ const stamp = Date.now()
+ const created = await page.request.post('/api/trips', {
+ data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' },
+ })
+ expect(created.ok(), 'seed trip').toBeTruthy()
+ const tripId = (await created.json()).trip?.id ?? (await created.json()).id
+
+ await page.goto(`/trips/${tripId}`)
+ await expect(activeChip(page)).toBeVisible({ timeout: 30_000 })
+
+ const chips = page.locator('button[aria-current], button[aria-current="true"]')
+ const rail = activeChip(page).locator('xpath=..')
+ expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5)
+ expect(await chips.count()).toBeGreaterThan(0)
+
+ // The swipe zone: below the chip rail, above the dock, across the middle.
+ const mid = Math.round(env.width / 2)
+ const y = 460
+
+ const day1 = (await activeChip(page).textContent())!.trim()
+
+ // 1. A swipe left steps forward.
+ await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
+ const day2 = (await activeChip(page).textContent())!.trim()
+ expect(day2, 'swiping left moved to the next day').not.toBe(day1)
+
+ // 2. A swipe right steps back to where it started.
+ await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
+ expect((await activeChip(page).textContent())!.trim()).toBe(day1)
+
+ // 3. The first day has nowhere to go back to — it must stay put, and the
+ // browser must not have been handed a page-back either.
+ await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
+ expect((await activeChip(page).textContent())!.trim()).toBe(day1)
+ expect(page.url()).toContain(`/trips/${tripId}`)
+
+ // 4. A vertical drag belongs to the card's own scroller, never to the day.
+ await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 })
+ expect((await activeChip(page).textContent())!.trim()).toBe(day1)
+
+ // 5. And the rail itself still works, so the gesture added a path rather than
+ // replacing one.
+ await rail.locator('button').nth(2).click()
+ await expect(activeChip(page)).not.toHaveText(day1)
+})
diff --git a/client/src/components/Map/PoiCategoryPill.test.tsx b/client/src/components/Map/PoiCategoryPill.test.tsx
index 94008a914..d1f79bdc4 100644
--- a/client/src/components/Map/PoiCategoryPill.test.tsx
+++ b/client/src/components/Map/PoiCategoryPill.test.tsx
@@ -1,4 +1,4 @@
-// FE-COMP-POIPILL-001 to FE-COMP-POIPILL-006
+// FE-COMP-POIPILL-001 to FE-COMP-POIPILL-008
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -57,4 +57,22 @@ describe('PoiCategoryPill', () => {
fireEvent.click(screen.getByText('Search this area'))
expect(onSearchArea).toHaveBeenCalled()
})
+
+ // The phone map hands the bar the whole width between the screen margins, so
+ // its segments end up the size of everything else the thumb aims at there.
+ it('FE-COMP-POIPILL-007: fullWidth stretches the bar and spreads the segments', () => {
+ pill({ fullWidth: true })
+ const button = screen.getAllByRole('button')[0]
+ expect(button.style.flexGrow).toBe('1')
+ expect(button.style.width).toBe('auto')
+ expect((button.parentElement as HTMLElement).style.display).toBe('flex')
+ })
+
+ it('FE-COMP-POIPILL-008: without it the bar stays content-width, as the desktop map floats it', () => {
+ pill()
+ const button = screen.getAllByRole('button')[0]
+ expect(button.style.flexGrow).toBe('')
+ expect(button.style.width).toBe('34px')
+ expect((button.parentElement as HTMLElement).style.display).toBe('inline-flex')
+ })
})
diff --git a/client/src/components/Map/PoiCategoryPill.tsx b/client/src/components/Map/PoiCategoryPill.tsx
index 2c544d134..7dd0ef59a 100644
--- a/client/src/components/Map/PoiCategoryPill.tsx
+++ b/client/src/components/Map/PoiCategoryPill.tsx
@@ -12,12 +12,16 @@ interface Props {
/** true when the map moved since the last search → offer "search this area" */
moved?: boolean
onSearchArea?: () => void
+ /** Stretch the bar across its container and spread the segments evenly.
+ * The phone map gives it the full width between the screen margins; on
+ * desktop it floats, so it stays content-width there. */
+ fullWidth?: boolean
}
// Frosted, icon-only segmented control that floats over the map. Active segments
// fill with the category colour (matching their markers); the label shows in a
// custom tooltip on hover so the pill stays compact and never needs to scroll.
-export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
+export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth }: Props) {
const { t } = useTranslation()
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
@@ -29,8 +33,12 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKe
}
return (
-
-
+
+
{POI_CATEGORIES.map(cat => {
const on = active.has(cat.key)
// Only an active category can be loading — a deselected one whose fetch
@@ -47,7 +55,11 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKe
style={{
position: 'relative',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
- width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
+ flexGrow: fullWidth ? 1 : undefined,
+ flexShrink: fullWidth ? 1 : undefined,
+ flexBasis: fullWidth ? 0 : undefined,
+ minWidth: fullWidth ? 0 : undefined,
+ width: fullWidth ? 'auto' : 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
color: on ? '#fff' : undefined,
transition: 'background 0.14s, color 0.14s',
diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx
index 69f1dd5a7..b4b6ebd90 100644
--- a/client/src/mobile/screens/trip/MTripShell.tsx
+++ b/client/src/mobile/screens/trip/MTripShell.tsx
@@ -217,6 +217,27 @@ export default function MTripShell({
planner.tripActions.setSelectedDay(findFocusDayId(days) ?? days[0].id)
}, [planner.selectedDayId, days, planner.tripActions])
+ // Swiping the day panel (#2051) can move the day well past the chips on
+ // screen — the rail overflows from roughly six days on — so the active chip
+ // pulls itself back into view. Only when it is really clipped, so tapping a
+ // chip you can already see never shifts the rail under your thumb.
+ const chipRefs = useRef(new Map
())
+ useEffect(() => {
+ const chip = planner.selectedDayId != null ? chipRefs.current.get(planner.selectedDayId) : undefined
+ const rail = chip?.parentElement
+ if (!chip || !rail) return
+ const c = chip.getBoundingClientRect()
+ const r = rail.getBoundingClientRect()
+ if (c.left >= r.left - 1 && c.right <= r.right + 1) return
+ // block:'nearest' is not optional: the shell root is `fixed inset-0`, and
+ // 'center' would scroll the document behind it instead.
+ chip.scrollIntoView({
+ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth',
+ inline: 'center',
+ block: 'nearest',
+ })
+ }, [planner.selectedDayId, days])
+
const trTab = planner.activeTab
const setTrTab = (tabId: string) => {
@@ -333,6 +354,7 @@ export default function MTripShell({
return (
{ if (el) chipRefs.current.set(day.id, el); else chipRefs.current.delete(day.id) }}
type="button"
onClick={() => onDayChipTap(day.id)}
aria-current={active ? 'true' : undefined}
diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx
index 86cd2253b..a61a44022 100644
--- a/client/src/mobile/screens/trip/map/MMapArea.tsx
+++ b/client/src/mobile/screens/trip/map/MMapArea.tsx
@@ -16,9 +16,10 @@ import type { MMapAreaProps } from '../MTripShell'
* setting) with the full desktop feature set: clusters, photo/icon markers,
* day-order badges, dashed day route, transport overlays per booking, POI
* explore markers and long-press → add place. Only the floating chrome is
- * mobile: the POI/compass cluster sits centre-top below the day-chip rail, and
- * the map's built-in three-state locate button is lifted above the dock via
- * the --bottom-nav-h contract it already reads.
+ * mobile: the POI bar spans the full width below the day-chip rail, and the two
+ * round controls share the band just above the dock — the compass on the left,
+ * the map's built-in three-state locate button on the right, both riding the
+ * --bottom-nav-h contract the map already reads so they cannot drift apart.
*
* Marker data honours the shared places category filter (#1541) because
* planner.mapPlaces is derived from tripStore's placesCategoryFilter — the
@@ -35,9 +36,10 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
// `isolate` keeps the map's internal z-indexes (Leaflet panes, the z-1000
// locate button) inside this layer so they can never paint over the plan
// timeline (z-10) or the browse/tab overlays (z-30) above it.
- // The dock is 62px tall at safe-bottom + 12, so a 104px --bottom-nav-h
- // floats the built-in locate button at the spec's 116px band above it.
-
+ // The dock is 62px tall at safe-bottom + 12, so a 74px --bottom-nav-h puts
+ // the round controls (which add their own 12px) a dock's gap above it —
+ // close enough to the thumb to reach one-handed, clear of the dock itself.
+
- {/* Floating map chrome — only while the map view is front-most. Centre-top,
- below the day-chip rail (safe-top + 50px + ~42px chip height). The
- compass renders on GL maps only (Leaflet can't rotate). */}
- {mapActive && (poiPillEnabled || glMap) && (
+ {/* Floating map chrome — only while the map view is front-most. The POI bar
+ sits below the day-chip rail (safe-top + 50px + ~42px chip height) and
+ takes the full width between the screen margins, so its segments are
+ the same size as everything else the thumb aims at on this screen. */}
+ {mapActive && poiPillEnabled && (
- {poiPillEnabled && (
-
- )}
- {glMap &&
}
+
+
+ )}
+
+ {/* Compass — GL maps only (Leaflet can't rotate). Bottom-left, mirroring
+ the locate button's own `right: 12` off the same --bottom-nav-h, so the
+ two round controls always sit on one line. Leaflet has no compass but
+ puts its base-layer switcher in the same corner, so the band reads the
+ same either way. */}
+ {mapActive && glMap && (
+
+
)}
diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
index 4f026ea70..e513cf900 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
@@ -1,4 +1,4 @@
-import { useState, type MouseEvent } from 'react'
+import { useRef, useState, type MouseEvent } from 'react'
import {
ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut,
MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2,
@@ -24,6 +24,8 @@ import type { MergedItem } from '../../../../utils/dayMerge'
import type { Assignment } from '../../../../types'
import type { ComponentType, ReactNode } from 'react'
import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon'
+import { isRtlLanguage } from '../../../../i18n'
+import { useMPlanDaySwipe } from './useMPlanDaySwipe'
/**
* Plan-tab timeline of the mobile trip screen: the UP-NEXT card in go mode,
@@ -85,6 +87,25 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
onMove: tl.moveRowTo,
enabled: editing,
})
+ // Swipe the whole panel left/right to step days (#2051) — the one-handed way
+ // to the next day, since the chip rail is pinned to the top of the screen.
+ const panelRef = useRef
(null)
+ const cardRef = useRef(null)
+ const daySwipe = useMPlanDaySwipe({
+ days: planner.days,
+ selectedDayId: planner.selectedDayId,
+ // skipFit, exactly like the chip tap in plan view: the map underneath stays
+ // mounted, and re-framing it here would move it somewhere nobody asked for.
+ onSelectDay: dayId => planner.handleSelectDay(dayId, true),
+ panelRef,
+ cardRef,
+ editing,
+ dragging: dnd.draggingKey != null,
+ menuOpen: legMenu.menu != null,
+ rtl: isRtlLanguage(planner.language),
+ describeDay: (i, n) => t('mobileTrip.dayAnnounce', { current: i + 1, total: n }),
+ })
+
const dragFor = (row: PlanRow): RowDrag | undefined => {
const props = dnd.dragPropsFor(row)
if (!props) return undefined
@@ -107,8 +128,12 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
const chrome = { editing, t, language: tl.language, timeFormat: tl.timeFormat }
return (
-
+
+ {/* Swipe-committed day changes only — a chip tap already speaks its own
+ button label plus the aria-current flip, so announcing there would say
+ the day twice. */}
+
{daySwipe.announcement}
{!editing &&
}
{editing &&
}
@@ -116,6 +141,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
collapses that reserved space up to the day chips when the day has no
up-next (no places), so an empty day shows no gap. */}
-
+ {/* The one horizontal scroller inside the swipe zone, marked so a swipe
+ that starts here is handed back to it — but only while it really
+ overflows, so a short day title still swipes. */}
+
) {
+ const panelRef = useRef(null)
+ const cardRef = useRef(null)
+ const swipe = useMPlanDaySwipe({
+ days: DAYS, selectedDayId: 12, onSelectDay, panelRef, cardRef,
+ editing: false, dragging: false, menuOpen: false, rtl: false,
+ describeDay: (i, n) => `dayAnnounce:${i + 1},${n}`,
+ ...props,
+ })
+ return (
+
+
{swipe.announcement}
+
+
+ row
+
+
+ )
+}
+
+/** Feeds the committed day straight back in, the way the planner does — the only
+ * way to show a burst of swipes stepping day by day rather than repeating one. */
+function StatefulHarness() {
+ const [dayId, setDayId] = useState(11)
+ return { onSelectDay(id); setDayId(id) }} />
+}
+
+const panel = () => screen.getByTestId('panel')
+const card = () => screen.getByTestId('card')
+const live = () => screen.getByTestId('live')
+
+function mount(props: Partial = {}) {
+ return prepare(render( ))
+}
+
+function mountStateful() {
+ return prepare(render( ))
+}
+
+function prepare(view: T): T {
+ const el = card()
+ Object.defineProperty(el, 'scrollTop', { get: () => scroll.top, configurable: true })
+ Object.defineProperty(el, 'scrollLeft', { get: () => scroll.left, configurable: true })
+ // jsdom has no Element.prototype.scrollTo, which is why the hook calls it
+ // optionally — the spy is what lets the reset be asserted at all.
+ el.scrollTo = vi.fn()
+ return view
+}
+
+function overflow(el: HTMLElement, scrollWidth: number, clientWidth: number) {
+ Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true })
+ Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true })
+}
+
+interface Point { x: number; y?: number; dt?: number }
+
+/** Plays a touch path. The first point is the press, the rest are moves; the
+ * press itself is never a move, so a path of one point is a plain tap. */
+function drag(target: HTMLElement, path: Point[]): Point {
+ const [start, ...moves] = path
+ fireEvent.touchStart(target, { touches: [{ clientX: start.x, clientY: start.y ?? 300 }] })
+ let last = start
+ for (const p of moves) {
+ if (p.dt) act(() => { vi.advanceTimersByTime(p.dt!) })
+ fireEvent.touchMove(target, { touches: [{ clientX: p.x, clientY: p.y ?? 300 }] })
+ last = p
+ }
+ return last
+}
+
+function release(target: HTMLElement, at: Point) {
+ fireEvent.touchEnd(target, { touches: [], changedTouches: [{ clientX: at.x, clientY: at.y ?? 300 }] })
+}
+
+/** Runs both animation legs plus the spring, so the DOM lands in its rest state. */
+function settle() {
+ act(() => { vi.advanceTimersByTime(600) })
+}
+
+/** A committing left swipe: 100px, fast enough to be a flick as well. */
+function swipeLeft(target = panel(), from = 300) {
+ const last = drag(target, [{ x: from }, { x: from + 3 }, { x: from - 20, dt: 30 }, { x: from - 100, dt: 30 }])
+ release(target, last)
+ settle()
+}
+
+function swipeRight(target = panel(), from = 300) {
+ const last = drag(target, [{ x: from }, { x: from - 3 }, { x: from + 20, dt: 30 }, { x: from + 100, dt: 30 }])
+ release(target, last)
+ settle()
+}
+
+beforeEach(() => {
+ onSelectDay.mockClear()
+ onRowClick.mockClear()
+ scroll.top = 0
+ scroll.left = 0
+ vi.useFakeTimers()
+ // Both animation legs hang off rAF; running it inline keeps the timers the
+ // only clock the tests have to drive.
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0 })
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ document.documentElement.removeAttribute('data-reduce-motion')
+})
+
+describe('useMPlanDaySwipe', () => {
+ describe('committing', () => {
+ it('FE-MOB-DAYSWIPE-001: a swipe left steps to the next day', () => {
+ mount()
+ swipeLeft()
+ expect(onSelectDay).toHaveBeenCalledTimes(1)
+ expect(onSelectDay).toHaveBeenCalledWith(13)
+ })
+
+ it('FE-MOB-DAYSWIPE-002: a swipe right steps to the previous day', () => {
+ mount()
+ swipeRight()
+ expect(onSelectDay).toHaveBeenCalledWith(11)
+ })
+
+ it('FE-MOB-DAYSWIPE-003: a short, slow drag springs back to rest', () => {
+ mount()
+ const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 300 }, { x: 270, dt: 300 }])
+ release(panel(), last)
+ expect(onSelectDay).not.toHaveBeenCalled()
+ settle()
+ expect(panel().style.transform).toBe('')
+ expect(panel().style.transition).toBe('')
+ })
+
+ it('FE-MOB-DAYSWIPE-004: a flick commits short of the distance threshold', () => {
+ mount()
+ const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 288, dt: 15 }, { x: 270, dt: 15 }])
+ release(panel(), last)
+ settle()
+ expect(onSelectDay).toHaveBeenCalledWith(13)
+ })
+
+ it('FE-MOB-DAYSWIPE-005: a fast but tiny move is not a flick', () => {
+ mount()
+ const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 288, dt: 10 }, { x: 280, dt: 10 }])
+ release(panel(), last)
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('yielding to the gestures that were already there', () => {
+ it('FE-MOB-DAYSWIPE-006: a vertical drag never touches the panel', () => {
+ mount()
+ const last = drag(panel(), [{ x: 300 }, { x: 302, y: 320, dt: 20 }, { x: 304, y: 420, dt: 20 }])
+ expect(panel().style.transform).toBe('')
+ release(panel(), last)
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-007: a diagonal drag inside the bias goes to the scroller', () => {
+ mount()
+ const last = drag(panel(), [{ x: 300 }, { x: 340, y: 338, dt: 20 }, { x: 400, y: 400, dt: 20 }])
+ release(panel(), last)
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-008: a card that has already scrolled vertically kills the gesture', () => {
+ mount()
+ fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
+ scroll.top = 12
+ fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
+ release(panel(), { x: 180 })
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ expect(panel().style.transform).toBe('')
+ })
+
+ it('FE-MOB-DAYSWIPE-009: a card that has already scrolled sideways kills it too', () => {
+ mount()
+ fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
+ scroll.left = 9
+ fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
+ release(panel(), { x: 180 })
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-017: a swipe started on an overflowing horizontal strip is handed back', () => {
+ mount()
+ const strip = screen.getByTestId('strip')
+ overflow(strip, 300, 200)
+ const last = drag(strip, [{ x: 300 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
+ release(strip, last)
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-018: the same strip swipes normally while it fits', () => {
+ mount()
+ const strip = screen.getByTestId('strip')
+ overflow(strip, 200, 200)
+ const last = drag(strip, [{ x: 300 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
+ release(strip, last)
+ settle()
+ expect(onSelectDay).toHaveBeenCalledWith(13)
+ })
+
+ it('FE-MOB-DAYSWIPE-019: no touch event is ever cancelled or stopped', () => {
+ mount()
+ const seen: { type: string; prevented: boolean }[] = []
+ const spy = (e: Event) => seen.push({ type: e.type, prevented: e.defaultPrevented })
+ for (const type of ['touchstart', 'touchmove', 'touchend']) document.addEventListener(type, spy)
+ try {
+ swipeLeft()
+ } finally {
+ for (const type of ['touchstart', 'touchmove', 'touchend']) document.removeEventListener(type, spy)
+ }
+ // Reaching document at all proves propagation was never stopped; the drag
+ // bridge's own 10px self-cancel depends on both halves of this.
+ expect(seen.map(s => s.type)).toContain('touchstart')
+ expect(seen.map(s => s.type)).toContain('touchmove')
+ expect(seen.map(s => s.type)).toContain('touchend')
+ expect(seen.every(s => !s.prevented)).toBe(true)
+ })
+ })
+
+ describe('refusing to arm', () => {
+ it('FE-MOB-DAYSWIPE-010: the last day resists instead of wrapping', () => {
+ mount({ selectedDayId: 13 })
+ const last = drag(panel(), [{ x: 300 }, { x: 280, dt: 20 }, { x: 100, dt: 20 }])
+ const pulled = Math.abs(parseFloat(panel().style.transform.replace(/[^-\d.]/g, '')))
+ expect(pulled).toBeLessThanOrEqual(48)
+ release(panel(), last)
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-011: the first day resists the other way', () => {
+ mount({ selectedDayId: 11 })
+ swipeRight()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-012: a single-day trip never arms', () => {
+ mount({ days: [DAYS[0]], selectedDayId: 11 })
+ swipeLeft()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ expect(panel().style.transform).toBe('')
+ })
+
+ it('FE-MOB-DAYSWIPE-013: a two-finger touch never arms', () => {
+ mount()
+ fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }, { clientX: 340, clientY: 300 }] })
+ fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
+ release(panel(), { x: 180 })
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-014: a row already being dragged owns the finger', () => {
+ mount({ dragging: true })
+ swipeLeft()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-015: an open leg menu owns the finger', () => {
+ mount({ menuOpen: true })
+ swipeLeft()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-016: the screen edges are left to the browser', () => {
+ mount()
+ swipeLeft(panel(), 8)
+ expect(onSelectDay).not.toHaveBeenCalled()
+ swipeRight(panel(), window.innerWidth - 8)
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('edit mode', () => {
+ it('FE-MOB-DAYSWIPE-028: a press held past the drag window stops being a swipe', () => {
+ mount({ editing: true })
+ fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
+ act(() => { vi.advanceTimersByTime(400) })
+ fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
+ release(panel(), { x: 180 })
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-029: go mode has no drag bridge, so a slow swipe still counts', () => {
+ mount({ editing: false })
+ fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
+ act(() => { vi.advanceTimersByTime(400) })
+ fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
+ release(panel(), { x: 180 })
+ settle()
+ expect(onSelectDay).toHaveBeenCalledWith(13)
+ })
+ })
+
+ describe('right to left', () => {
+ it('FE-MOB-DAYSWIPE-020: an RTL locale mirrors both directions', () => {
+ mount({ rtl: true })
+ swipeLeft()
+ expect(onSelectDay).toHaveBeenCalledWith(11)
+ onSelectDay.mockClear()
+ swipeRight()
+ expect(onSelectDay).toHaveBeenCalledWith(13)
+ })
+ })
+
+ describe('taps and clicks', () => {
+ it('FE-MOB-DAYSWIPE-021: the click a swipe leaves behind never reaches a row', () => {
+ mount()
+ swipeLeft()
+ fireEvent.click(screen.getByTestId('row'))
+ expect(onRowClick).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DAYSWIPE-022: only that one click is swallowed', () => {
+ mount()
+ swipeLeft()
+ fireEvent.click(screen.getByTestId('row'))
+ fireEvent.click(screen.getByTestId('row'))
+ expect(onRowClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-DAYSWIPE-023: a plain tap opens its row, and a stray touchend does not throw', () => {
+ mount()
+ release(panel(), { x: 300 })
+ drag(panel(), [{ x: 300 }])
+ release(panel(), { x: 300 })
+ fireEvent.click(screen.getByTestId('row'))
+ expect(onRowClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-DAYSWIPE-024: a cancelled gesture springs back and changes nothing', () => {
+ mount()
+ drag(panel(), [{ x: 300 }, { x: 280, dt: 20 }, { x: 200, dt: 20 }])
+ fireEvent.touchCancel(panel(), { touches: [], changedTouches: [{ clientX: 200, clientY: 300 }] })
+ settle()
+ expect(onSelectDay).not.toHaveBeenCalled()
+ expect(panel().style.transform).toBe('')
+ })
+ })
+
+ describe('after the day changes', () => {
+ it('FE-MOB-DAYSWIPE-025: the new day starts at the top of the card', () => {
+ mount()
+ swipeLeft()
+ expect(card().scrollTo).toHaveBeenCalledWith({ top: 0 })
+ })
+
+ it('FE-MOB-DAYSWIPE-026: the live region names the day that was reached', () => {
+ mount()
+ swipeLeft()
+ expect(live()).toHaveTextContent('dayAnnounce:3,3')
+ })
+
+ it('FE-MOB-DAYSWIPE-027: mounting announces nothing', () => {
+ mount()
+ expect(live()).toHaveTextContent('')
+ })
+ })
+
+ describe('reduced motion', () => {
+ it('FE-MOB-DAYSWIPE-030: the day still changes, the panel just never moves', () => {
+ document.documentElement.setAttribute('data-reduce-motion', '')
+ mount()
+ const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
+ expect(panel().style.transform).toBe('')
+ release(panel(), last)
+ settle()
+ expect(onSelectDay).toHaveBeenCalledWith(13)
+ expect(live()).toHaveTextContent('dayAnnounce:3,3')
+ expect(panel().style.transform).toBe('')
+ })
+ })
+
+ describe('interruption', () => {
+ it('FE-MOB-DAYSWIPE-031: a burst of swipes steps day by day, and unmounting mid-animation is quiet', () => {
+ const stateful = mountStateful()
+ // The second swipe starts before the first one's out-leg has landed, so
+ // the interrupt has to commit day 1 → 2 before day 2 → 3 can resolve.
+ const first = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
+ release(panel(), first)
+ const second = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
+ release(panel(), second)
+ settle()
+ expect(onSelectDay.mock.calls.map(c => c[0])).toEqual([12, 13])
+ stateful.unmount()
+
+ onSelectDay.mockClear()
+ const view = mount()
+ const third = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
+ release(panel(), third)
+ view.unmount()
+ act(() => { vi.advanceTimersByTime(600) })
+ expect(onSelectDay).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/client/src/mobile/screens/trip/plan/useMPlanDaySwipe.ts b/client/src/mobile/screens/trip/plan/useMPlanDaySwipe.ts
new file mode 100644
index 000000000..cc1cf4032
--- /dev/null
+++ b/client/src/mobile/screens/trip/plan/useMPlanDaySwipe.ts
@@ -0,0 +1,369 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { flushSync } from 'react-dom'
+import type { MouseEvent as ReactMouseEvent, RefObject, TouchEvent as ReactTouchEvent } from 'react'
+import type { Day } from '../../../../types'
+
+/**
+ * Swipe the mobile day panel left/right to step through the trip's days (#2051).
+ *
+ * The phone shell puts the days on a chip rail pinned to the top of the screen,
+ * which on a large phone is a thin target a thumb cannot reach one-handed. The
+ * day details fill the rest of the screen and did nothing, so this hands that
+ * whole surface the same job the rail has.
+ *
+ * The gesture is purely observational: it never calls preventDefault and never
+ * stops propagation on a touch event, and it sets no touch-action. Two reasons.
+ * The browser keeps sole ownership of the card's vertical scroll, so the worst a
+ * misread can do is translate the panel and spring it back. And the long-press
+ * reorder bridge (utils/touchDragBridge) listens on `document`, below React's
+ * root — swallowing a touchmove would starve its own 10px self-cancel and leave
+ * it arming a drag on a row the finger had already left.
+ */
+
+/** Movement before the gesture is classified. Same figure as SystemNoticeModal. */
+const CLASSIFY_PX = 8
+/** |dx| must beat |dy| by this to claim horizontal. Scrolling is the gesture
+ * this card already had, so it wins the ties. */
+const AXIS_BIAS = 1.2
+/** Travel that commits on release. */
+const COMMIT_PX = 56
+/** A flick commits short of COMMIT_PX. 0.5 px/ms sits under MSheet's 0.6 dismiss
+ * — turning a page is a smaller motion than throwing a sheet off screen. */
+const FLICK_V = 0.5
+const FLICK_MIN_PX = 24
+/** Velocity is measured over the trailing window, not the last frame: the final
+ * move before lift is often a pixel or two and reads as a standstill. */
+const SAMPLE_MS = 100
+/** Rubber band: 1:1 up to FREE, then heavy, then a hard stop. */
+const RUBBER_FREE_PX = 72
+const RUBBER_FACTOR = 0.35
+const RUBBER_MAX_PX = 132
+/** On the first and last day there is nowhere to go, so the pull is far stiffer. */
+const EDGE_FACTOR = 0.18
+const EDGE_MAX_PX = 48
+/** iOS' interactive back/forward strip cannot be turned off, only avoided. The
+ * card's own left edge is at 16px, so this costs its outermost 8px as a start. */
+const EDGE_GUTTER_PX = 24
+/** Kept in step with touchDragBridge's LONG_PRESS_MS. Past this window the press
+ * belongs to the reorder drag. Edit mode only — go mode never installs the
+ * bridge, so a leisurely one-handed swipe still counts there. */
+const PRESS_WINDOW_MS = 320
+const OUT_MS = 150
+const IN_MS = 220
+const REDUCED_MS = 120
+/** Percent of the panel width the outgoing / incoming day travels. */
+const OUT_OFFSET = 22
+const IN_OFFSET = 14
+const EASE = 'var(--ease-drawer)'
+const SPRING = 'cubic-bezier(0.34,1.56,0.64,1)'
+
+type Lock = null | 'h' | 'dead'
+
+interface Gesture {
+ x0: number
+ y0: number
+ t0: number
+ scrollTop0: number
+ scrollLeft0: number
+ lock: Lock
+ samples: { x: number; t: number }[]
+}
+
+function applyX(el: HTMLElement | null, px: string | null): void {
+ if (!el) return
+ el.style.transform = px == null ? '' : `translateX(${px})`
+}
+
+function band(dx: number, free: number, factor: number, max: number): number {
+ const a = Math.abs(dx)
+ return Math.sign(dx) * Math.min(a <= free ? a : free + (a - free) * factor, max)
+}
+
+/** The media query plus the app's own appearance toggle, which writes the same
+ * attribute PluginFrame reads. */
+function reducedMotion(): boolean {
+ if (typeof window === 'undefined') return false
+ if (document.documentElement.hasAttribute('data-reduce-motion')) return true
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
+}
+
+export interface MPlanDaySwipeArgs {
+ days: Day[]
+ selectedDayId: number | null
+ /** Always called with skipFit — see the call site in MPlanTimeline for why. */
+ onSelectDay: (dayId: number) => void
+ /** The element that gets translated: MPlanTimeline's `absolute inset-0` root. */
+ panelRef: RefObject
+ /** The frosted timeline card. Its scroll offsets are how we ask the browser
+ * whether it has already claimed the gesture. */
+ cardRef: RefObject
+ /** Edit mode: the long-press reorder bridge is installed and competes. */
+ editing: boolean
+ /** True once the bridge has armed and fired its dragstart. */
+ dragging: boolean
+ /** True while the per-leg travel-mode menu is open. */
+ menuOpen: boolean
+ /** Flips next/prev, same rule the notice pager uses. */
+ rtl: boolean
+ /** Live-region text for a committed swipe. */
+ describeDay: (index: number, total: number) => string
+}
+
+export interface MPlanDaySwipe {
+ /** Announced to screen readers after a swipe commits — empty until then. */
+ announcement: string
+ handlers: {
+ onTouchStart: (e: ReactTouchEvent) => void
+ onTouchMove: (e: ReactTouchEvent) => void
+ onTouchEnd: (e: ReactTouchEvent) => void
+ onTouchCancel: () => void
+ onClickCapture: (e: ReactMouseEvent) => void
+ }
+}
+
+export function useMPlanDaySwipe({
+ days, selectedDayId, onSelectDay, panelRef, cardRef,
+ editing, dragging, menuOpen, rtl, describeDay,
+}: MPlanDaySwipeArgs): MPlanDaySwipe {
+ const gesture = useRef(null)
+ const swallowClick = useRef(false)
+ /** The one scheduled animation step. Never more than one is outstanding. */
+ const pending = useRef<{ timer: number; run: () => void } | null>(null)
+ const alive = useRef(true)
+ const [announcement, setAnnouncement] = useState('')
+
+ // The handlers are stable callbacks reading per-render values, so a ref mirror
+ // keeps them current. `dragging` in particular has to be read this way: the
+ // reorder hook's setDraggingKey runs inside the dragstart the bridge fires, so
+ // a render-scoped read lags by a commit — exactly the window we bail in.
+ const latest = useRef({ days, selectedDayId, onSelectDay, editing, dragging, menuOpen, rtl, describeDay })
+ latest.current = { days, selectedDayId, onSelectDay, editing, dragging, menuOpen, rtl, describeDay }
+
+ const cancelPending = useCallback(() => {
+ const p = pending.current
+ pending.current = null
+ if (p) window.clearTimeout(p.timer)
+ }, [])
+
+ /** Terminal state: byte-identical to the idle DOM. Never queues another step —
+ * a settle that re-enters the queue is what makes a spring-back recurse. */
+ const settle = useCallback(() => {
+ // A live drag owns the panel: a stale timer left over from the previous
+ // animation must not wipe the transform out from under the finger.
+ if (gesture.current?.lock === 'h') return
+ const el = panelRef.current
+ if (!el) return
+ el.style.transition = ''
+ el.style.transform = ''
+ }, [panelRef])
+
+ const schedule = useCallback((ms: number, run: () => void) => {
+ cancelPending()
+ const timer = window.setTimeout(() => { pending.current = null; run() }, ms)
+ pending.current = { timer, run }
+ }, [cancelPending])
+
+ /** Runs the outstanding step now. Interruption only — never on unmount, where
+ * it would land in a tree that is already tearing down. */
+ const finishPending = useCallback(() => {
+ const p = pending.current
+ pending.current = null
+ if (!p) return
+ window.clearTimeout(p.timer)
+ p.run()
+ }, [])
+
+ /** Resolved at release from the live days array, by index — never from an index
+ * captured at touchstart, and never day_number ± 1: day_number is optional and
+ * a delete leaves gaps. */
+ const targetFor = useCallback((dx: number): number | null => {
+ const s = latest.current
+ // Finger to the left means forward in LTR; RTL mirrors it.
+ const step = (dx < 0) === !s.rtl ? 1 : -1
+ const i = s.days.findIndex(d => d.id === s.selectedDayId)
+ // Dangling selection: a remote day:deleted drops the day but leaves
+ // selectedDayId pointing at it, which shows a blank timeline. A swipe is a
+ // cheap way back out of that.
+ if (i < 0) return s.days[0]?.id ?? null
+ return s.days[i + step]?.id ?? null
+ }, [])
+
+ /** Commits the day and parks the panel where the incoming leg starts. */
+ const handoff = useCallback((dayId: number, fromPct: number | null) => {
+ if (!alive.current) return
+ const el = panelRef.current
+ if (el && fromPct != null) {
+ el.style.transition = 'none'
+ applyX(el, `${fromPct}%`)
+ }
+ const s = latest.current
+ const idx = s.days.findIndex(d => d.id === dayId)
+ const apply = () => {
+ s.onSelectDay(dayId)
+ if (idx >= 0) setAnnouncement(s.describeDay(idx, s.days.length))
+ }
+ // Flushed so the new day is in the DOM before the incoming leg is armed in
+ // the same frame; otherwise one frame paints the old day off-centre.
+ if (fromPct == null) apply()
+ else flushSync(apply)
+ // The new day starts at the top.
+ cardRef.current?.scrollTo?.({ top: 0 })
+
+ if (!el || fromPct == null) { settle(); return }
+ requestAnimationFrame(() => {
+ if (!alive.current) return
+ el.style.transition = `transform ${IN_MS}ms ${EASE}`
+ applyX(el, '0px')
+ // transitionend does not fire in a backgrounded tab, under a display:none
+ // ancestor, or in jsdom. The timer is both the production net and what the
+ // tests drive.
+ schedule(IN_MS + 40, settle)
+ })
+ }, [cardRef, panelRef, schedule, settle])
+
+ const commit = useCallback((dx: number): boolean => {
+ const target = targetFor(dx)
+ if (target == null) return false
+ const el = panelRef.current
+
+ if (!el || reducedMotion()) {
+ handoff(target, null)
+ // Gentler, not zero — the same call mobile.css makes for its own reduced
+ // path. The fade goes on the card, never on the panel: an ancestor with
+ // opacity < 1 becomes a backdrop root and cancels the card's own blur.
+ const card = cardRef.current
+ if (card) {
+ card.style.transition = 'none'
+ card.style.opacity = '0'
+ requestAnimationFrame(() => {
+ if (!alive.current) return
+ card.style.transition = `opacity ${REDUCED_MS}ms ease-out`
+ card.style.opacity = ''
+ window.setTimeout(() => { card.style.transition = '' }, REDUCED_MS + 20)
+ })
+ }
+ return true
+ }
+
+ const forward = dx < 0
+ el.style.transition = `transform ${OUT_MS}ms ease-out`
+ applyX(el, `${forward ? -OUT_OFFSET : OUT_OFFSET}%`)
+ schedule(OUT_MS, () => handoff(target, forward ? IN_OFFSET : -IN_OFFSET))
+ return true
+ }, [cardRef, handoff, panelRef, schedule, targetFor])
+
+ const springBack = useCallback(() => {
+ const el = panelRef.current
+ if (!el) return
+ if (reducedMotion()) { settle(); return }
+ el.style.transition = `transform 300ms ${SPRING}`
+ applyX(el, '0px')
+ schedule(340, settle)
+ }, [panelRef, schedule, settle])
+
+ const onTouchStart = useCallback((e: ReactTouchEvent) => {
+ // Interruption lands the outstanding commit rather than dropping it, so
+ // three swipes in a burst move three days.
+ finishPending()
+ swallowClick.current = false
+ gesture.current = null
+ const s = latest.current
+ if (e.touches.length !== 1) return
+ if (s.days.length < 2 || s.dragging || s.menuOpen) return
+ const touch = e.touches[0]
+ if (touch.clientX < EDGE_GUTTER_PX || touch.clientX > window.innerWidth - EDGE_GUTTER_PX) return
+ // The header chip strip owns its own pan, but only while it really overflows
+ // — a short day title with no hotel chips still swipes.
+ const strip = (e.target as Element | null)?.closest?.('[data-hswipe-ignore]')
+ if (strip && strip.scrollWidth > strip.clientWidth + 1) return
+ const now = Date.now()
+ gesture.current = {
+ x0: touch.clientX, y0: touch.clientY, t0: now,
+ scrollTop0: cardRef.current?.scrollTop ?? 0,
+ scrollLeft0: cardRef.current?.scrollLeft ?? 0,
+ lock: null,
+ samples: [{ x: touch.clientX, t: now }],
+ }
+ }, [cardRef, finishPending])
+
+ const onTouchMove = useCallback((e: ReactTouchEvent) => {
+ const g = gesture.current
+ if (!g || g.lock === 'dead' || e.touches.length !== 1) return
+ const touch = e.touches[0]
+ const now = Date.now()
+ const dx = touch.clientX - g.x0
+ const dy = touch.clientY - g.y0
+ g.samples.push({ x: touch.clientX, t: now })
+ while (g.samples.length > 2 && now - g.samples[0].t > SAMPLE_MS) g.samples.shift()
+
+ if (g.lock === null) {
+ const card = cardRef.current
+ // The browser has already committed this gesture to scrolling, so obey it.
+ // scrollLeft counts too: overflow-y-auto on its own leaves overflow-x
+ // computing to auto, and a wide markdown table in a day note fills it.
+ if (card && (card.scrollTop !== g.scrollTop0 || card.scrollLeft !== g.scrollLeft0)) {
+ g.lock = 'dead'
+ return
+ }
+ if (Math.abs(dx) <= CLASSIFY_PX && Math.abs(dy) <= CLASSIFY_PX) return
+ const s = latest.current
+ // Edit mode only: past the bridge's press window this finger belongs to a
+ // reorder drag. Go mode has no bridge, so a slow swipe still counts.
+ if (s.dragging || (s.editing && now - g.t0 > PRESS_WINDOW_MS)) { g.lock = 'dead'; return }
+ g.lock = Math.abs(dx) >= Math.abs(dy) * AXIS_BIAS ? 'h' : 'dead'
+ if (g.lock === 'dead') return
+ }
+
+ if (reducedMotion()) return // classify and commit, but never follow the finger
+ const el = panelRef.current
+ if (!el) return
+ el.style.transition = 'none'
+ applyX(el, `${targetFor(dx) == null
+ ? band(dx, 0, EDGE_FACTOR, EDGE_MAX_PX)
+ : band(dx, RUBBER_FREE_PX, RUBBER_FACTOR, RUBBER_MAX_PX)}px`)
+ }, [cardRef, panelRef, targetFor])
+
+ const onTouchEnd = useCallback((e: ReactTouchEvent) => {
+ const g = gesture.current
+ gesture.current = null
+ if (!g || g.lock !== 'h') return
+ // The finger travelled far enough sideways to be a swipe rather than a tap,
+ // so the click the browser may still send is not one the rows should see.
+ swallowClick.current = true
+ const dx = (e.changedTouches[0]?.clientX ?? g.x0) - g.x0
+ const first = g.samples[0]
+ const last = g.samples[g.samples.length - 1]
+ const v = (last.x - first.x) / Math.max(1, last.t - first.t)
+ const far = Math.abs(dx) > COMMIT_PX
+ const flick = Math.abs(v) > FLICK_V && Math.abs(dx) > FLICK_MIN_PX && Math.sign(v) === Math.sign(dx)
+ if ((far || flick) && commit(dx)) return
+ springBack()
+ }, [commit, springBack])
+
+ const onTouchCancel = useCallback(() => {
+ const g = gesture.current
+ gesture.current = null
+ if (g?.lock === 'h') springBack()
+ }, [springBack])
+
+ /** Root-scoped on purpose, unlike the drag bridge's one-shot document listener:
+ * that one eats an innocent click 400ms later whenever the expected click never
+ * arrives, and it would also swallow the document click the leg menu closes on.
+ * The flag is cleared on every fresh touchstart, so it cannot go stale. */
+ const onClickCapture = useCallback((e: ReactMouseEvent) => {
+ if (!swallowClick.current) return
+ swallowClick.current = false
+ e.preventDefault()
+ e.stopPropagation()
+ }, [])
+
+ useEffect(() => {
+ alive.current = true
+ return () => { alive.current = false; cancelPending() }
+ }, [cancelPending])
+
+ return {
+ announcement,
+ handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, onClickCapture },
+ }
+}
diff --git a/client/tests/unit/mobile/trip/MMapArea.test.tsx b/client/tests/unit/mobile/trip/MMapArea.test.tsx
new file mode 100644
index 000000000..628944385
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MMapArea.test.tsx
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '../../../helpers/render'
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
+import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import type { CompassMap } from '../../../../src/components/Map/MapCompassPill'
+import { useSettingsStore } from '../../../../src/store/settingsStore'
+
+// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-007
+
+const mocks = vi.hoisted(() => ({
+ poi: {} as Record,
+ /** Handed to onMapReady, standing in for a GL map that can rotate. */
+ glMap: null as CompassMap | null,
+ /** Last props the area handed the renderer, so its callbacks can be fired. */
+ props: {} as Record,
+}))
+
+// The real renderer boots Leaflet/MapLibre; the area only ever hands it props.
+vi.mock('../../../../src/components/Map/MapViewAuto', () => ({
+ MapViewAuto: (props: Record) => {
+ mocks.props = props
+ ;(props.onMapReady as ((map: CompassMap | null) => void) | undefined)?.(mocks.glMap)
+ return
+ },
+}))
+
+vi.mock('../../../../src/components/Map/usePoiExplore', () => ({
+ usePoiExplore: () => mocks.poi,
+}))
+
+import MMapArea from '../../../../src/mobile/screens/trip/map/MMapArea'
+
+const COMPASS: CompassMap = {
+ getBearing: () => 0,
+ on: vi.fn(),
+ off: vi.fn(),
+ easeTo: vi.fn(),
+}
+
+function renderArea(shellOver: Partial = {}, plannerOver: Partial = {}) {
+ const planner = buildPlanner(plannerOver)
+ const shell = buildShell({ view: 'map', ...shellOver })
+ return { planner, shell, ...render( ) }
+}
+
+/** The compass wrapper is the only element carrying an inline bottom offset. */
+const compassBand = (container: HTMLElement) =>
+ container.querySelector('[style*="--bottom-nav-h"]') as HTMLElement | null
+
+beforeEach(() => {
+ mocks.glMap = COMPASS
+ mocks.poi = {
+ active: new Set(), pois: [], loadingKeys: new Set(), errorKeys: new Set(),
+ moved: false, toggle: vi.fn(), searchArea: vi.fn(), onViewportChange: vi.fn(),
+ }
+ useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: true } }))
+})
+
+describe('MMapArea', () => {
+ it('FE-MOB-MAPAREA-001: the POI bar takes the full width between the screen margins', () => {
+ renderArea()
+
+ const segment = screen.getAllByRole('button')[0]
+ expect(segment.style.flexGrow).toBe('1')
+ })
+
+ it('FE-MOB-MAPAREA-002: the compass rides the same bottom offset as the locate button', () => {
+ const { container } = renderArea()
+
+ // LocationButton hard-codes `right: 12` off the same variable, so matching
+ // the offset here is what keeps the two round controls on one line.
+ expect(compassBand(container)?.style.bottom).toBe('calc(var(--bottom-nav-h, 84px) + 12px)')
+ expect(compassBand(container)?.className).toContain('left-3')
+ })
+
+ it('FE-MOB-MAPAREA-003: the map layer floats those controls a dock gap above the dock', () => {
+ const { container } = renderArea()
+
+ // The dock is 62px tall at safe-bottom + 12; the controls add their own 12.
+ expect((container.firstElementChild as HTMLElement).className)
+ .toContain('[--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]')
+ })
+
+ it('FE-MOB-MAPAREA-004: a renderer that cannot rotate gets no compass', () => {
+ mocks.glMap = null
+ const { container } = renderArea()
+
+ expect(compassBand(container)).toBeNull()
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
+ })
+
+ it('FE-MOB-MAPAREA-005: turning the POI bar off leaves the compass alone', () => {
+ useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: false } }))
+ const { container } = renderArea()
+
+ expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
+ expect(compassBand(container)).not.toBeNull()
+ })
+
+ it('FE-MOB-MAPAREA-006: no floating chrome while the timeline covers the map', () => {
+ const { container } = renderArea({ view: 'plan' })
+
+ expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
+ expect(compassBand(container)).toBeNull()
+ // The renderer itself stays mounted so tiles and markers keep their warmth.
+ expect(screen.getByTestId('map-renderer')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-MAPAREA-007: a transport overlay tap opens the mobile transport sheet', () => {
+ const { shell } = renderArea()
+
+ ;(mocks.props.onReservationClick as (id: number) => void)(7)
+
+ expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 7 })
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
index dcab49488..6cf3118da 100644
--- a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
+++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { fireEvent, render, screen, within } from '../../../helpers/render'
+import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import type { PluginDaySchedule } from '../../../../src/components/Plugins/PluginDaySchedule'
import type { MPlanTimelineController } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
@@ -9,7 +9,7 @@ import type { MergedItem } from '../../../../src/utils/dayMerge'
import type { Assignment, Day, DayNote, Place, RouteSegment } from '../../../../src/types'
import MPlanTimeline from '../../../../src/mobile/screens/trip/plan/MPlanTimeline'
-// FE-MOB-PLTL-001 to FE-MOB-PLTL-038
+// FE-MOB-PLTL-001 to FE-MOB-PLTL-045
const mocks = vi.hoisted(() => ({
tl: {} as Record,
@@ -572,4 +572,82 @@ describe('MPlanTimeline', () => {
expect(mocks.tl.openTransitJourney).toHaveBeenCalledWith(TRANSIT_RES)
})
})
+ describe('day swipe (#2051)', () => {
+ const DAYS = [
+ { id: 1, trip_id: 1, day_number: 1 }, DAY, { id: 3, trip_id: 1, day_number: 3 },
+ ] as unknown as Day[]
+
+ const panel = (container: HTMLElement) => container.firstElementChild as HTMLElement
+
+ /** A committing left swipe across the whole panel. */
+ function swipe(el: HTMLElement, from = 300, to = 180) {
+ fireEvent.touchStart(el, { touches: [{ clientX: from, clientY: 300 }] })
+ fireEvent.touchMove(el, { touches: [{ clientX: from - 20, clientY: 300 }] })
+ fireEvent.touchMove(el, { touches: [{ clientX: to, clientY: 300 }] })
+ fireEvent.touchEnd(el, { touches: [], changedTouches: [{ clientX: to, clientY: 300 }] })
+ }
+
+ it('FE-MOB-PLTL-039: swiping the panel selects the next day without re-framing the map', async () => {
+ const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
+
+ swipe(panel(container))
+
+ // skipFit must be true: the map stays mounted under the timeline, and
+ // re-fitting it here would move it somewhere nobody asked for.
+ await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true))
+ })
+
+ it('FE-MOB-PLTL-040: the header chip strip is excluded from the swipe zone', () => {
+ const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
+
+ expect(container.querySelector('[data-hswipe-ignore]')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-PLTL-041: the live region is present and silent until a swipe lands', () => {
+ const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
+
+ const live = container.querySelector('[role="status"]') as HTMLElement
+ expect(live).toHaveAttribute('aria-live', 'polite')
+ expect(live).toHaveAttribute('aria-atomic', 'true')
+ expect(live).toHaveTextContent('')
+ })
+
+ it('FE-MOB-PLTL-042: a committed swipe announces the day it reached', async () => {
+ const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
+
+ swipe(panel(container))
+
+ await waitFor(() => expect(container.querySelector('[role="status"]'))
+ .toHaveTextContent('mobileTrip.dayAnnounce:3,3'))
+ })
+
+ it('FE-MOB-PLTL-043: edit mode keeps both the swipe and the long-press reorder', async () => {
+ const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }, { mode: 'edit' })
+
+ swipe(panel(container))
+
+ await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true))
+ expect(card(container)).toHaveAttribute('data-touch-drag')
+ })
+
+ it('FE-MOB-PLTL-044: a plain tap on a place row still opens it', () => {
+ const { planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
+
+ fireEvent.click(screen.getByText('Ueno Park'))
+
+ expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12)
+ })
+
+ it('FE-MOB-PLTL-045: nothing narrows touch-action, and the excluded strip stays tappable', () => {
+ const { container, shell } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
+
+ // touch-action intersects down the tree, so a pan-y here would take the
+ // header strip's own horizontal pan with it.
+ expect(panel(container).style.touchAction).toBe('')
+ expect(card(container).style.touchAction).toBe('')
+
+ fireEvent.click(screen.getByLabelText('day.overview'))
+ expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
+ })
+ })
})
diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx
index 2cd606ada..54f38a04b 100644
--- a/client/tests/unit/mobile/trip/MTripShell.test.tsx
+++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx
@@ -1,10 +1,10 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day, PackingItem, TodoItem } from '../../../../src/types'
-// FE-MOB-SHELL-001 to FE-MOB-SHELL-034
+// FE-MOB-SHELL-001 to FE-MOB-SHELL-046
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }))
@@ -461,6 +461,49 @@ describe('MTripShell', () => {
act(() => { shellApi.openSheet('note', { dayId: 12 }) })
expect(shellApi.sheet).toEqual({ id: 'note', payload: { dayId: 12 } })
})
+
+ // The chip rail overflows from roughly six days on, and swiping the day panel
+ // (#2051) can move the day well past what is on screen.
+ describe('chip rail auto-scroll', () => {
+ const RAIL = { left: 0, right: 300 } as DOMRect
+ /** Rects are all zero without layout, so the two boxes are stubbed by role. */
+ function stubRects(chip: Partial) {
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
+ return (this.tagName === 'BUTTON' ? { ...RAIL, ...chip } : RAIL) as DOMRect
+ })
+ }
+
+ afterEach(() => { vi.restoreAllMocks() })
+
+ it('FE-MOB-SHELL-044: a clipped active chip pulls itself into the middle of the rail', () => {
+ const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
+ scrollIntoView.mockClear()
+ stubRects({ left: 480, right: 540 })
+ renderShell({ selectedDayId: 12 } as Partial)
+ expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', inline: 'center', block: 'nearest' })
+ })
+
+ it('FE-MOB-SHELL-045: a chip already in view never shifts the rail under the thumb', () => {
+ const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
+ scrollIntoView.mockClear()
+ stubRects({ left: 40, right: 100 })
+ renderShell({ selectedDayId: 12 } as Partial)
+ expect(scrollIntoView).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-SHELL-046: reduced motion jumps instead of gliding', () => {
+ const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
+ scrollIntoView.mockClear()
+ stubRects({ left: 480, right: 540 })
+ vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
+ matches: query.includes('reduced-motion'), media: query, onchange: null,
+ addListener: vi.fn(), removeListener: vi.fn(),
+ addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
+ }) as unknown as MediaQueryList)
+ renderShell({ selectedDayId: 12 } as Partial)
+ expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', inline: 'center', block: 'nearest' })
+ })
+ })
})
describe('buildShell fixture', () => {
diff --git a/shared/src/i18n/ar/mobileTrip.ts b/shared/src/i18n/ar/mobileTrip.ts
index 17ee03bac..d24ef470d 100644
--- a/shared/src/i18n/ar/mobileTrip.ts
+++ b/shared/src/i18n/ar/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'لا توجد حجوزات بعد',
'mobileTrip.collabFeatureDisabled': 'هذه الميزة معطّلة لهذه الرحلة',
'mobileTrip.compactView': 'عرض مضغوط',
+ 'mobileTrip.dayAnnounce': 'اليوم {current} من {total}',
'mobileTrip.dayTitlePlaceholder': 'عنوان اليوم',
'mobileTrip.export': 'تصدير',
'mobileTrip.filesEmpty': 'لا توجد ملفات بعد',
diff --git a/shared/src/i18n/br/mobileTrip.ts b/shared/src/i18n/br/mobileTrip.ts
index 487c25bc7..b5479352b 100644
--- a/shared/src/i18n/br/mobileTrip.ts
+++ b/shared/src/i18n/br/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Nenhuma reserva ainda',
'mobileTrip.collabFeatureDisabled': 'Esse recurso está desativado para esta viagem',
'mobileTrip.compactView': 'Visualização compacta',
+ 'mobileTrip.dayAnnounce': 'Dia {current} de {total}',
'mobileTrip.dayTitlePlaceholder': 'Título do dia',
'mobileTrip.export': 'Exportar',
'mobileTrip.filesEmpty': 'Nenhum arquivo ainda',
diff --git a/shared/src/i18n/ca/mobileTrip.ts b/shared/src/i18n/ca/mobileTrip.ts
index bcf9f30be..27aa619a5 100644
--- a/shared/src/i18n/ca/mobileTrip.ts
+++ b/shared/src/i18n/ca/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Encara no hi ha reserves',
'mobileTrip.collabFeatureDisabled': 'Aquesta funció està desactivada per a aquest viatge',
'mobileTrip.compactView': 'Vista compacta',
+ 'mobileTrip.dayAnnounce': 'Dia {current} de {total}',
'mobileTrip.dayTitlePlaceholder': 'Títol del dia',
'mobileTrip.export': 'Exporta',
'mobileTrip.filesEmpty': 'Encara no hi ha fitxers',
diff --git a/shared/src/i18n/cs/mobileTrip.ts b/shared/src/i18n/cs/mobileTrip.ts
index 1466e9fea..19ee08448 100644
--- a/shared/src/i18n/cs/mobileTrip.ts
+++ b/shared/src/i18n/cs/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Zatím žádné rezervace',
'mobileTrip.collabFeatureDisabled': 'Tato funkce je pro tento výlet vypnuta',
'mobileTrip.compactView': 'Kompaktní zobrazení',
+ 'mobileTrip.dayAnnounce': 'Den {current} z {total}',
'mobileTrip.dayTitlePlaceholder': 'Název dne',
'mobileTrip.export': 'Exportovat',
'mobileTrip.filesEmpty': 'Zatím žádné soubory',
diff --git a/shared/src/i18n/de/mobileTrip.ts b/shared/src/i18n/de/mobileTrip.ts
index bdd00834f..f23e2b7a5 100644
--- a/shared/src/i18n/de/mobileTrip.ts
+++ b/shared/src/i18n/de/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Noch keine Buchungen',
'mobileTrip.collabFeatureDisabled': 'Diese Funktion ist für diese Reise deaktiviert',
'mobileTrip.compactView': 'Kompakte Ansicht',
+ 'mobileTrip.dayAnnounce': 'Tag {current} von {total}',
'mobileTrip.dayTitlePlaceholder': 'Tagestitel',
'mobileTrip.export': 'Exportieren',
'mobileTrip.filesEmpty': 'Keine Dateien vorhanden',
diff --git a/shared/src/i18n/en/mobileTrip.ts b/shared/src/i18n/en/mobileTrip.ts
index 58f6265fd..a3b9701aa 100644
--- a/shared/src/i18n/en/mobileTrip.ts
+++ b/shared/src/i18n/en/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'No bookings yet',
'mobileTrip.collabFeatureDisabled': 'This feature is disabled for this trip',
'mobileTrip.compactView': 'Compact view',
+ 'mobileTrip.dayAnnounce': 'Day {current} of {total}',
'mobileTrip.dayTitlePlaceholder': 'Day title',
'mobileTrip.export': 'Export',
'mobileTrip.filesEmpty': 'No files yet',
diff --git a/shared/src/i18n/es/mobileTrip.ts b/shared/src/i18n/es/mobileTrip.ts
index b29bf9c5e..37f3d6305 100644
--- a/shared/src/i18n/es/mobileTrip.ts
+++ b/shared/src/i18n/es/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Aún no hay reservas',
'mobileTrip.collabFeatureDisabled': 'Esta función está desactivada para este viaje',
'mobileTrip.compactView': 'Vista compacta',
+ 'mobileTrip.dayAnnounce': 'Día {current} de {total}',
'mobileTrip.dayTitlePlaceholder': 'Título del día',
'mobileTrip.export': 'Exportar',
'mobileTrip.filesEmpty': 'Aún no hay archivos',
diff --git a/shared/src/i18n/fr/mobileTrip.ts b/shared/src/i18n/fr/mobileTrip.ts
index 254ca427f..dc7960032 100644
--- a/shared/src/i18n/fr/mobileTrip.ts
+++ b/shared/src/i18n/fr/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Aucune réservation',
'mobileTrip.collabFeatureDisabled': 'Cette fonctionnalité est désactivée pour ce voyage',
'mobileTrip.compactView': 'Vue compacte',
+ 'mobileTrip.dayAnnounce': 'Jour {current} sur {total}',
'mobileTrip.dayTitlePlaceholder': 'Titre du jour',
'mobileTrip.export': 'Exporter',
'mobileTrip.filesEmpty': 'Aucun fichier',
diff --git a/shared/src/i18n/gr/mobileTrip.ts b/shared/src/i18n/gr/mobileTrip.ts
index 92df6574e..c24c979c0 100644
--- a/shared/src/i18n/gr/mobileTrip.ts
+++ b/shared/src/i18n/gr/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Δεν υπάρχουν κρατήσεις ακόμη',
'mobileTrip.collabFeatureDisabled': 'Αυτή η λειτουργία είναι απενεργοποιημένη για αυτό το ταξίδι',
'mobileTrip.compactView': 'Συμπαγής προβολή',
+ 'mobileTrip.dayAnnounce': 'Ημέρα {current} από {total}',
'mobileTrip.dayTitlePlaceholder': 'Τίτλος ημέρας',
'mobileTrip.export': 'Εξαγωγή',
'mobileTrip.filesEmpty': 'Δεν υπάρχουν αρχεία ακόμη',
diff --git a/shared/src/i18n/hu/mobileTrip.ts b/shared/src/i18n/hu/mobileTrip.ts
index cc0055822..c64549c9f 100644
--- a/shared/src/i18n/hu/mobileTrip.ts
+++ b/shared/src/i18n/hu/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Még nincsenek foglalások',
'mobileTrip.collabFeatureDisabled': 'Ez a funkció ki van kapcsolva ennél az utazásnál',
'mobileTrip.compactView': 'Kompakt nézet',
+ 'mobileTrip.dayAnnounce': '{current}. nap / {total}',
'mobileTrip.dayTitlePlaceholder': 'Nap címe',
'mobileTrip.export': 'Exportálás',
'mobileTrip.filesEmpty': 'Még nincsenek fájlok',
diff --git a/shared/src/i18n/id/mobileTrip.ts b/shared/src/i18n/id/mobileTrip.ts
index 27e5b29f2..3fec72dba 100644
--- a/shared/src/i18n/id/mobileTrip.ts
+++ b/shared/src/i18n/id/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Belum ada reservasi',
'mobileTrip.collabFeatureDisabled': 'Fitur ini dinonaktifkan untuk perjalanan ini',
'mobileTrip.compactView': 'Tampilan ringkas',
+ 'mobileTrip.dayAnnounce': 'Hari {current} dari {total}',
'mobileTrip.dayTitlePlaceholder': 'Judul hari',
'mobileTrip.export': 'Ekspor',
'mobileTrip.filesEmpty': 'Belum ada file',
diff --git a/shared/src/i18n/it/mobileTrip.ts b/shared/src/i18n/it/mobileTrip.ts
index 3b2d63763..014fa7555 100644
--- a/shared/src/i18n/it/mobileTrip.ts
+++ b/shared/src/i18n/it/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Ancora nessuna prenotazione',
'mobileTrip.collabFeatureDisabled': 'Questa funzione è disattivata per questo viaggio',
'mobileTrip.compactView': 'Vista compatta',
+ 'mobileTrip.dayAnnounce': 'Giorno {current} di {total}',
'mobileTrip.dayTitlePlaceholder': 'Titolo del giorno',
'mobileTrip.export': 'Esporta',
'mobileTrip.filesEmpty': 'Ancora nessun file',
diff --git a/shared/src/i18n/ja/mobileTrip.ts b/shared/src/i18n/ja/mobileTrip.ts
index 482e52c76..73caf3865 100644
--- a/shared/src/i18n/ja/mobileTrip.ts
+++ b/shared/src/i18n/ja/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': '予約はまだありません',
'mobileTrip.collabFeatureDisabled': 'この機能はこの旅行では無効になっています',
'mobileTrip.compactView': 'コンパクト表示',
+ 'mobileTrip.dayAnnounce': '{current}日目 / 全{total}日',
'mobileTrip.dayTitlePlaceholder': '日のタイトル',
'mobileTrip.export': '書き出し',
'mobileTrip.filesEmpty': 'ファイルはまだありません',
diff --git a/shared/src/i18n/ko/mobileTrip.ts b/shared/src/i18n/ko/mobileTrip.ts
index 426b740fc..190365613 100644
--- a/shared/src/i18n/ko/mobileTrip.ts
+++ b/shared/src/i18n/ko/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': '아직 예약이 없습니다',
'mobileTrip.collabFeatureDisabled': '이 여행에서는 이 기능이 비활성화되어 있습니다',
'mobileTrip.compactView': '간략히 보기',
+ 'mobileTrip.dayAnnounce': '{total}일 중 {current}일째',
'mobileTrip.dayTitlePlaceholder': '일차 제목',
'mobileTrip.export': '내보내기',
'mobileTrip.filesEmpty': '아직 파일이 없습니다',
diff --git a/shared/src/i18n/nl/mobileTrip.ts b/shared/src/i18n/nl/mobileTrip.ts
index 1afeb7a6e..8e7987eb8 100644
--- a/shared/src/i18n/nl/mobileTrip.ts
+++ b/shared/src/i18n/nl/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Nog geen boekingen',
'mobileTrip.collabFeatureDisabled': 'Deze functie is uitgeschakeld voor deze reis',
'mobileTrip.compactView': 'Compacte weergave',
+ 'mobileTrip.dayAnnounce': 'Dag {current} van {total}',
'mobileTrip.dayTitlePlaceholder': 'Titel van de dag',
'mobileTrip.export': 'Exporteren',
'mobileTrip.filesEmpty': 'Nog geen bestanden',
diff --git a/shared/src/i18n/pl/mobileTrip.ts b/shared/src/i18n/pl/mobileTrip.ts
index 94e63fe76..14405c7ad 100644
--- a/shared/src/i18n/pl/mobileTrip.ts
+++ b/shared/src/i18n/pl/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Brak rezerwacji',
'mobileTrip.collabFeatureDisabled': 'Ta funkcja jest wyłączona dla tej podróży',
'mobileTrip.compactView': 'Widok kompaktowy',
+ 'mobileTrip.dayAnnounce': 'Dzień {current} z {total}',
'mobileTrip.dayTitlePlaceholder': 'Tytuł dnia',
'mobileTrip.export': 'Eksportuj',
'mobileTrip.filesEmpty': 'Brak plików',
diff --git a/shared/src/i18n/ru/mobileTrip.ts b/shared/src/i18n/ru/mobileTrip.ts
index d26fcf508..4412a0ea2 100644
--- a/shared/src/i18n/ru/mobileTrip.ts
+++ b/shared/src/i18n/ru/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Пока нет бронирований',
'mobileTrip.collabFeatureDisabled': 'Эта функция отключена для этой поездки',
'mobileTrip.compactView': 'Компактный вид',
+ 'mobileTrip.dayAnnounce': 'День {current} из {total}',
'mobileTrip.dayTitlePlaceholder': 'Название дня',
'mobileTrip.export': 'Экспорт',
'mobileTrip.filesEmpty': 'Файлов пока нет',
diff --git a/shared/src/i18n/sv/mobileTrip.ts b/shared/src/i18n/sv/mobileTrip.ts
index 9be27dfea..fbe9be394 100644
--- a/shared/src/i18n/sv/mobileTrip.ts
+++ b/shared/src/i18n/sv/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Inga bokningar än',
'mobileTrip.collabFeatureDisabled': 'Den här funktionen är inaktiverad för den här resan',
'mobileTrip.compactView': 'Kompakt vy',
+ 'mobileTrip.dayAnnounce': 'Dag {current} av {total}',
'mobileTrip.dayTitlePlaceholder': 'Dagens titel',
'mobileTrip.export': 'Exportera',
'mobileTrip.filesEmpty': 'Inga filer ännu',
diff --git a/shared/src/i18n/tr/mobileTrip.ts b/shared/src/i18n/tr/mobileTrip.ts
index 1a9ee3aee..6665f1dba 100644
--- a/shared/src/i18n/tr/mobileTrip.ts
+++ b/shared/src/i18n/tr/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Henüz rezervasyon yok',
'mobileTrip.collabFeatureDisabled': 'Bu özellik bu seyahat için devre dışı',
'mobileTrip.compactView': 'Kompakt görünüm',
+ 'mobileTrip.dayAnnounce': 'Gün {current} / {total}',
'mobileTrip.dayTitlePlaceholder': 'Gün başlığı',
'mobileTrip.export': 'Dışa aktar',
'mobileTrip.filesEmpty': 'Henüz dosya yok',
diff --git a/shared/src/i18n/uk/mobileTrip.ts b/shared/src/i18n/uk/mobileTrip.ts
index 0aa0290b1..d8b5c796a 100644
--- a/shared/src/i18n/uk/mobileTrip.ts
+++ b/shared/src/i18n/uk/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Поки немає бронювань',
'mobileTrip.collabFeatureDisabled': 'Цю функцію вимкнено для цієї подорожі',
'mobileTrip.compactView': 'Компактний вигляд',
+ 'mobileTrip.dayAnnounce': 'День {current} з {total}',
'mobileTrip.dayTitlePlaceholder': 'Назва дня',
'mobileTrip.export': 'Експорт',
'mobileTrip.filesEmpty': 'Файлів поки немає',
diff --git a/shared/src/i18n/vi/mobileTrip.ts b/shared/src/i18n/vi/mobileTrip.ts
index bb24e1022..592002bd2 100644
--- a/shared/src/i18n/vi/mobileTrip.ts
+++ b/shared/src/i18n/vi/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': 'Chưa có đặt chỗ nào',
'mobileTrip.collabFeatureDisabled': 'Tính năng này bị tắt cho chuyến đi này',
'mobileTrip.compactView': 'Chế độ xem gọn',
+ 'mobileTrip.dayAnnounce': 'Ngày {current} trong {total}',
'mobileTrip.dayTitlePlaceholder': 'Tiêu đề ngày',
'mobileTrip.export': 'Xuất',
'mobileTrip.filesEmpty': 'Chưa có tập tin nào',
diff --git a/shared/src/i18n/zh-TW/mobileTrip.ts b/shared/src/i18n/zh-TW/mobileTrip.ts
index 3018e1a9a..4ea3e4a7c 100644
--- a/shared/src/i18n/zh-TW/mobileTrip.ts
+++ b/shared/src/i18n/zh-TW/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': '暫無預訂',
'mobileTrip.collabFeatureDisabled': '此功能已針對本次行程停用',
'mobileTrip.compactView': '精簡檢視',
+ 'mobileTrip.dayAnnounce': '第 {current} 天,共 {total} 天',
'mobileTrip.dayTitlePlaceholder': '天數標題',
'mobileTrip.export': '匯出',
'mobileTrip.filesEmpty': '暫無檔案',
diff --git a/shared/src/i18n/zh/mobileTrip.ts b/shared/src/i18n/zh/mobileTrip.ts
index 94156a4b6..ecb1641fb 100644
--- a/shared/src/i18n/zh/mobileTrip.ts
+++ b/shared/src/i18n/zh/mobileTrip.ts
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.bookingsEmpty': '暂无预订',
'mobileTrip.collabFeatureDisabled': '此功能已针对本次行程停用',
'mobileTrip.compactView': '紧凑视图',
+ 'mobileTrip.dayAnnounce': '第 {current} 天,共 {total} 天',
'mobileTrip.dayTitlePlaceholder': '天数标题',
'mobileTrip.export': '导出',
'mobileTrip.filesEmpty': '暂无文件',