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 ( +
+
+ ) +} + +/** 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': '暂无文件',