diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
index 5f4cb548a..dbdb71bcb 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react'
import {
- ArrowRight, ArrowUpRight, BedDouble, CalendarRange, ChevronRight, Compass, LogIn, LogOut,
+ ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut,
MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2,
Car, Footprints, Zap, RotateCcw,
} from 'lucide-react'
@@ -10,14 +10,20 @@ import { fmtTransitDuration } from '../../../../components/Planner/transitDispla
import { formatTime } from '../../../../utils/formatters'
import { useMPlanTimeline, type MPlanTimelineController } from './useMPlanTimeline'
import { cityPillsForDay, weatherIconFor } from './planTimelineModel'
+import type { PlanRow } from './planTimelineModel'
+import { useMPlanDragReorder } from './useMPlanDragReorder'
+import { useTouchDragBridge } from '../../../../hooks/useTouchDragBridge'
+import { useIsTouch } from '../../../../hooks/useIsTouch'
import { ConnRow, HotelConnRow, NoteRow, PlaceRow, PlanScheduleRow, ReorderStack, TransitRow, TransportRow } from './MPlanTimelineRows'
+import type { RowDrag } from './MPlanTimelineRows'
import { usePluginDaySchedule } from '../../../../components/Plugins/PluginDaySchedule'
import { Fragment } from 'react'
import MDancingTrek from '../../../components/MDancingTrek'
import type { MPlanTimelineProps } from '../MTripShell'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { Assignment } from '../../../../types'
-import type { ReactNode } from 'react'
+import type { ComponentType, ReactNode } from 'react'
+import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon'
/**
* Plan-tab timeline of the mobile trip screen: the UP-NEXT card in go mode,
@@ -48,6 +54,10 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
const daySchedule = usePluginDaySchedule(planner.tripId)
const day = tl.day
const dayId = day?.id
+ // Mirrors MDaySheet's own label so the pill and the sheet it opens agree.
+ const dayLabel = day
+ ? day.title || t('planner.dayN', { n: day.day_number || planner.days.indexOf(day) + 1 })
+ : ''
const dayScheduleFor = (anchor: 'assignment' | 'reservation', id: number) =>
(dayId != null
? (anchor === 'assignment' ? daySchedule.byAssignment[dayId]?.[id] : daySchedule.byReservation[dayId]?.[id])
@@ -60,6 +70,27 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
planner.handlePlaceClick(assignment.place?.id ?? null, assignment.id)
}
+ // Long-press drag reordering (#1997). Armed only in edit mode, so go-mode taps,
+ // map pans and plain list scrolling keep the gesture — which is what kept
+ // #1432/#1440 from coming back when the old document-wide polyfill was dropped.
+ // Coarse pointers only: a hybrid laptop narrow enough to land in the phone
+ // shell already loads drag-drop-touch (utils/touchDragPolyfill), and two
+ // bridges would fight over one gesture. The rows' native drag props serve
+ // both, so nothing is lost there.
+ const isTouch = useIsTouch()
+ useTouchDragBridge(editing && isTouch)
+ const dnd = useMPlanDragReorder({
+ merged: tl.merged,
+ dayId,
+ onMove: tl.moveRowTo,
+ enabled: editing,
+ })
+ const dragFor = (row: PlanRow): RowDrag | undefined => {
+ const props = dnd.dragPropsFor(row)
+ if (!props) return undefined
+ return { ...props, dragging: dnd.draggingKey === row.key, dropTarget: dnd.dropBeforeKey === row.key }
+ }
+
const reorderFor = (item: MergedItem): ReactNode => {
const idx = tl.merged.indexOf(item)
return (
@@ -85,11 +116,19 @@ 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. */}
- {(tl.hotelChips.length > 0 || tl.weatherTemp != null) && (
-
day && shell.openSheet('day', { dayId: day.id })} />
+ {day && (
+ shell.openSheet('day', { dayId: day.id })}
+ />
)}
{tl.hotelLegs.top && (
@@ -108,6 +147,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
linkedRes={row.linkedRes}
chrome={chrome}
reorder={reorderFor(row.item)}
+ drag={dragFor(row)}
onOpen={() => openPlace(row.assignment)}
onEdit={() => tl.editAssignment(row.assignment)}
onRemove={() => tl.removeAssignment(row.assignment)}
@@ -123,6 +163,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
dayId={day.id}
chrome={chrome}
reorder={reorderFor(row.item)}
+ drag={dragFor(row)}
onOpen={() => {
if (editing) tl.editTransport(row.res)
else shell.openSheet('transport', { reservationId: row.res.id })
@@ -141,6 +182,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
open={tl.openTransitKeys.has(row.key)}
chrome={chrome}
reorder={reorderFor(row.item)}
+ drag={dragFor(row)}
onToggle={() => tl.toggleTransit(row.key)}
onOpenJourney={() => tl.openTransitJourney(row.res)}
/>
@@ -154,6 +196,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
note={row.note}
chrome={chrome}
reorder={reorderFor(row.item)}
+ drag={dragFor(row)}
onEdit={() => shell.openSheet('note', { dayId: day.id, note: row.note })}
/>
)
@@ -185,7 +228,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
void tl.optimize()} />
-
+
)}
@@ -316,12 +359,34 @@ function EditHeader({ tl, planner, shell }: {
)
}
-/** Card header: accommodation chips (check-out / check-in / stay) + the weather chip. */
-function TimelineHeader({ tl, onOpenDay }: { tl: MPlanTimelineController; onOpenDay: () => void }) {
+/**
+ * Card header: the day pill that opens the day sheet, the accommodation chips
+ * (check-out / check-in / stay) and the weather chip.
+ *
+ * The day pill leads unconditionally. It used to be the accommodation chip that
+ * carried this, which left a day without a stay — and, with no weather either,
+ * the whole header — with no way into the day sheet at all (#2004).
+ */
+function TimelineHeader({ tl, dayLabel, openLabel, onOpenDay }: {
+ tl: MPlanTimelineController
+ dayLabel: string
+ openLabel: string
+ onOpenDay: () => void
+}) {
const WeatherIcon = weatherIconFor(tl.weather?.main)
return (
+
+
+ {dayLabel}
+
+
{tl.hotelChips.map(chip => (
{tl.weatherTemp != null && (
-
+
{tl.weatherTemp}°
-
+
)}
)
@@ -353,7 +423,12 @@ function HotelChipIcon({ variant }: { variant: 'checkout' | 'checkin' | 'stay' }
}
/** Edit-mode action tile — Place · Note · Booking · Transport · Optimize · Google Maps, three per row. */
-function PlanAction({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) {
+function PlanAction({ icon: Icon, label, onClick }: {
+ // Google Maps hands in its own brand mark, which is not a lucide icon (#2005).
+ icon: LucideIcon | ComponentType<{ size?: number; className?: string }>
+ label: string
+ onClick: () => void
+}) {
return (
+ !d ? '' : `${d.dragging ? 'opacity-40' : ''} ${d.dropTarget && !d.dragging ? 'shadow-[inset_0_2px_0_0_var(--m-act)]' : ''}`
+
+/** Only the props a DOM node takes — `dragging`/`dropTarget` are ours. */
+const dragProps = (d?: RowDrag) =>
+ d ? { draggable: d.draggable, onDragStart: d.onDragStart, onDragOver: d.onDragOver, onDrop: d.onDrop, onDragEnd: d.onDragEnd } : {}
+
const fmtTime = (time: string | null | undefined, c: RowChrome): string =>
time ? formatTime(time.slice(0, 5), c.language, c.timeFormat) : ''
@@ -100,12 +119,13 @@ const TIME_CHIP = 'flex-none whitespace-nowrap rounded-[6px] bg-[color:var(--m-i
// ── b3) Place row ────────────────────────────────────────────────────────────
-export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, onOpen, onEdit, onRemove }: {
+export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, drag, onOpen, onEdit, onRemove }: {
assignment: Assignment
fullPlace: Place | undefined
linkedRes: Reservation | null
chrome: RowChrome
reorder: ReactNode
+ drag?: RowDrag
onOpen: () => void
onEdit: () => void
onRemove: () => void
@@ -122,7 +142,7 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, on
: place?.address || place?.description || ''
return (
-
+
{!chrome.editing && (
void
}) {
const Icon = RES_ICONS[res.type as keyof typeof RES_ICONS] || Ticket
@@ -192,7 +213,7 @@ export function TransportRow({ res, dayId, chrome, reorder, onOpen }: {
const sub = transportSubtitle(res)
return (
-
+
{!chrome.editing && (
@@ -313,13 +334,14 @@ function TransitLegRows({ legs, t }: { legs: TransitLegDisplay[]; t: Translation
)
}
-export function TransitRow({ res, transit, dayId, open, chrome, reorder, onToggle, onOpenJourney }: {
+export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, onToggle, onOpenJourney }: {
res: TransportEntry
transit: TransitMeta
dayId: number
open: boolean
chrome: RowChrome
reorder: ReactNode
+ drag?: RowDrag
onToggle: () => void
onOpenJourney: () => void
}) {
@@ -331,7 +353,7 @@ export function TransitRow({ res, transit, dayId, open, chrome, reorder, onToggl
const Chevron = open ? ChevronUp : ChevronDown
return (
-
+
{!chrome.editing && (
@@ -458,10 +480,11 @@ export function HotelConnRow({ seg, name, placement }: {
// ── b5) Day-note row ─────────────────────────────────────────────────────────
-export function NoteRow({ note, chrome, reorder, onEdit }: {
+export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
note: DayNote
chrome: RowChrome
reorder: ReactNode
+ drag?: RowDrag
onEdit: () => void
}) {
const Icon = getNoteIcon(note.icon)
@@ -474,8 +497,9 @@ export function NoteRow({ note, chrome, reorder, onEdit }: {
return (
{!chrome.editing && (
diff --git a/client/src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts b/client/src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts
new file mode 100644
index 000000000..a7412b40f
--- /dev/null
+++ b/client/src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { useMPlanDragReorder } from './useMPlanDragReorder'
+import type { MergedItem } from '../../../../utils/dayMerge'
+import type { PlanRow } from './planTimelineModel'
+
+const items = [
+ { type: 'place', data: { id: 1 } },
+ { type: 'place', data: { id: 2 } },
+ { type: 'note', data: { id: 3 } },
+] as unknown as MergedItem[]
+
+const rowFor = (i: number): PlanRow =>
+ ({ key: `row-${i}`, kind: 'place', item: items[i], assignment: {}, linkedRes: null }) as unknown as PlanRow
+
+const connRow = { key: 'conn-1', kind: 'conn', seg: {} } as unknown as PlanRow
+
+/** A DragEvent stand-in with just the parts the hook touches. */
+const dragEvent = () => {
+ const store = new Map()
+ return {
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ dataTransfer: {
+ setData: (k: string, v: string) => store.set(k, v),
+ getData: (k: string) => store.get(k) ?? '',
+ set effectAllowed(_v: string) { /* noop */ },
+ },
+ } as never
+}
+
+const setup = (enabled = true) => {
+ const onMove = vi.fn()
+ const hook = renderHook(() => useMPlanDragReorder({ merged: items, dayId: 7, onMove, enabled }))
+ return { ...hook, onMove }
+}
+
+describe('useMPlanDragReorder', () => {
+ it('FE-MOB-DRAG-001: hands out nothing while disabled', () => {
+ const { result } = setup(false)
+ expect(result.current.dragPropsFor(rowFor(0))).toBeUndefined()
+ expect(result.current.draggingKey).toBeNull()
+ })
+
+ it('FE-MOB-DRAG-002: hands out nothing without a day', () => {
+ const onMove = vi.fn()
+ const { result } = renderHook(() =>
+ useMPlanDragReorder({ merged: items, dayId: null, onMove, enabled: true }))
+ expect(result.current.dragPropsFor(rowFor(0))).toBeUndefined()
+ })
+
+ it('FE-MOB-DRAG-003: leaves connector rows undraggable — they are computed spacing', () => {
+ const { result } = setup()
+ expect(result.current.dragPropsFor(connRow)).toBeUndefined()
+ })
+
+ it('FE-MOB-DRAG-004: marks the travelling row and the row it would land in front of', () => {
+ const { result } = setup()
+ act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
+ expect(result.current.draggingKey).toBe('row-0')
+
+ act(() => { result.current.dragPropsFor(rowFor(2))!.onDragOver(dragEvent()) })
+ expect(result.current.dropBeforeKey).toBe('row-2')
+ })
+
+ it('FE-MOB-DRAG-005: moves the source to the target index on drop', () => {
+ const { result, onMove } = setup()
+ act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
+ act(() => { result.current.dragPropsFor(rowFor(2))!.onDrop(dragEvent()) })
+
+ expect(onMove).toHaveBeenCalledWith(items[0], 2)
+ // and the drag state is cleared again
+ expect(result.current.draggingKey).toBeNull()
+ expect(result.current.dropBeforeKey).toBeNull()
+ })
+
+ it('FE-MOB-DRAG-006: a drop on the row itself is not a move', () => {
+ const { result, onMove } = setup()
+ act(() => { result.current.dragPropsFor(rowFor(1))!.onDragStart(dragEvent()) })
+ act(() => { result.current.dragPropsFor(rowFor(1))!.onDrop(dragEvent()) })
+ expect(onMove).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DRAG-007: a drop without a drag start is ignored', () => {
+ const { result, onMove } = setup()
+ act(() => { result.current.dragPropsFor(rowFor(1))!.onDrop(dragEvent()) })
+ expect(onMove).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DRAG-008: dragOver accepts the drop so the touch bridge can read it', () => {
+ const { result } = setup()
+ act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
+ const e = dragEvent() as unknown as { preventDefault: ReturnType }
+ act(() => { result.current.dragPropsFor(rowFor(1))!.onDragOver(e as never) })
+ expect(e.preventDefault).toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DRAG-009: dragEnd clears the state without moving anything', () => {
+ const { result, onMove } = setup()
+ act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
+ act(() => { result.current.dragPropsFor(rowFor(0))!.onDragEnd() })
+ expect(result.current.draggingKey).toBeNull()
+ expect(onMove).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-DRAG-010: survives a DataTransfer shim that refuses setData', () => {
+ const { result } = setup()
+ const broken = {
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ dataTransfer: { setData: () => { throw new Error('nope') } },
+ } as never
+ expect(() => act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(broken) })).not.toThrow()
+ expect(result.current.draggingKey).toBe('row-0')
+ })
+})
diff --git a/client/src/mobile/screens/trip/plan/useMPlanDragReorder.ts b/client/src/mobile/screens/trip/plan/useMPlanDragReorder.ts
new file mode 100644
index 000000000..604088c40
--- /dev/null
+++ b/client/src/mobile/screens/trip/plan/useMPlanDragReorder.ts
@@ -0,0 +1,105 @@
+import { useCallback, useRef, useState, type DragEvent } from 'react'
+import type { MergedItem } from '../../../../utils/dayMerge'
+import type { PlanRow } from './planTimelineModel'
+
+interface DragReorderArgs {
+ /** The day's items in merged order — the space a reorder is expressed in. */
+ merged: MergedItem[]
+ dayId: number | null | undefined
+ /** Same mover the up/down buttons use, so both paths share every guard. */
+ onMove: (item: MergedItem, toIndex: number) => void
+ enabled: boolean
+}
+
+export interface DragRowProps {
+ draggable: boolean
+ onDragStart: (e: DragEvent) => void
+ onDragOver: (e: DragEvent) => void
+ onDrop: (e: DragEvent) => void
+ onDragEnd: () => void
+}
+
+export interface MPlanDragReorder {
+ /** Key of the row under the finger, so it can be dimmed while it travels. */
+ draggingKey: string | null
+ /** Key of the row the drop would land in front of, for the insertion line. */
+ dropBeforeKey: string | null
+ dragPropsFor: (row: PlanRow) => DragRowProps | undefined
+}
+
+const NO_DRAG: MPlanDragReorder = {
+ draggingKey: null,
+ dropBeforeKey: null,
+ dragPropsFor: () => undefined,
+}
+
+/**
+ * Long-press drag reordering for the mobile day timeline (#1997).
+ *
+ * The phone shell shipped with up/down buttons only, on the reasoning that a
+ * finger cannot start an HTML5 drag (#1265) and that the document-wide
+ * `drag-drop-touch` polyfill which used to paper over that broke list scrolling
+ * (#1432) and hijacked map pans (#1440). `utils/touchDragBridge` already solved
+ * that for tablets (#1616): it watches only `[draggable]` rows inside an opted-in
+ * `[data-touch-drag]` container, waits out a 320ms press before it claims the
+ * gesture — so a swipe is still a scroll — and then replays real drag events.
+ * This hook is the receiving half the mobile timeline was missing.
+ *
+ * Rows carry native drag props, which means the same code also works with a
+ * mouse (tablet with a trackpad, desktop dev tools) at no extra cost.
+ *
+ * The up/down buttons stay exactly where they are: they are the accessible path,
+ * and they are what still works when a drag is not practical.
+ */
+export function useMPlanDragReorder({ merged, dayId, onMove, enabled }: DragReorderArgs): MPlanDragReorder {
+ const [draggingKey, setDraggingKey] = useState(null)
+ const [dropBeforeKey, setDropBeforeKey] = useState(null)
+ // The bridge's DataTransfer is a shim on browsers that refuse `new DataTransfer()`,
+ // so the source also rides along in a ref — same as the desktop sidebar does.
+ const sourceRef = useRef(null)
+
+ const clear = useCallback(() => {
+ sourceRef.current = null
+ setDraggingKey(null)
+ setDropBeforeKey(null)
+ }, [])
+
+ const dragPropsFor = useCallback((row: PlanRow): DragRowProps | undefined => {
+ // Connector rows are computed spacing between two stops, not items.
+ if (!enabled || dayId == null || row.kind === 'conn') return undefined
+ const item = row.item
+
+ return {
+ draggable: true,
+ onDragStart: (e: DragEvent) => {
+ sourceRef.current = item
+ setDraggingKey(row.key)
+ try {
+ e.dataTransfer.setData('planRowKey', row.key)
+ e.dataTransfer.effectAllowed = 'move'
+ } catch { /* the bridge's shim may not implement every DataTransfer member */ }
+ },
+ onDragOver: (e: DragEvent) => {
+ if (!sourceRef.current) return
+ // preventDefault is how a drop target says yes — the bridge reads it too.
+ e.preventDefault()
+ e.stopPropagation()
+ setDropBeforeKey(prev => (prev === row.key ? prev : row.key))
+ },
+ onDrop: (e: DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const source = sourceRef.current
+ clear()
+ if (!source || source === item) return
+ const to = merged.indexOf(item)
+ if (to < 0) return
+ onMove(source, to)
+ },
+ onDragEnd: clear,
+ }
+ }, [enabled, dayId, merged, onMove, clear])
+
+ if (!enabled) return NO_DRAG
+ return { draggingKey, dropBeforeKey, dragPropsFor }
+}
diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
index 7bf2ec6d1..dcab49488 100644
--- a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
+++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
@@ -208,10 +208,13 @@ describe('MPlanTimeline', () => {
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
})
- it('FE-MOB-PLTL-007: is left out entirely without chips and without weather', () => {
+ it('FE-MOB-PLTL-007: still renders, with the day pill, without chips or weather (#2004)', () => {
const { container } = renderTimeline()
- expect(container.querySelector('.border-b')).toBeNull()
+ // The pill is the only way into the day sheet, so it may not depend on the
+ // day happening to have an accommodation or a weather reading.
+ expect(container.querySelector('.border-b')).not.toBeNull()
+ expect(screen.getByRole('button', { name: 'day.overview' })).toBeInTheDocument()
})
it('FE-MOB-PLTL-008: draws the hotel bookend legs above and below the rows', () => {
@@ -349,16 +352,15 @@ describe('MPlanTimeline', () => {
})
it('FE-MOB-PLTL-020: read-only members get no tappable connectors', () => {
- const { container } = renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
+ renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
- expect(container.querySelector('button')).toBeNull()
+ expect(screen.getByText('7 min').closest('button')).toBeNull()
})
it('FE-MOB-PLTL-020b: go mode keeps the connectors read-only even with edit rights', () => {
- const { container } = renderTimeline()
+ renderTimeline()
expect(screen.getByText('7 min').closest('button')).toBeNull()
- expect(container.querySelector('button')).toBeNull()
})
})
@@ -439,6 +441,8 @@ describe('MPlanTimeline', () => {
renderTimeline({ day: { ...DAY, title: null } }, {}, { mode: 'edit' })
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
+ // …and the day pill next to it stays icon-only so the name is not doubled.
+ expect(screen.getByRole('button', { name: 'day.overview' })).toHaveTextContent('')
})
it('FE-MOB-PLTL-028: the pencil renames the day and Enter commits the change', () => {