From 331954c1b0fb9a8d0633cf58d9c2ea0d0bd8a318 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 21 Aug 2026 12:48:04 +0200 Subject: [PATCH] feat(mobile): drag to reorder the day plan, and reach the day sheet from any day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reordering a day on the phone meant two 15px arrows, while the same list on the web takes a drag. It takes one here too now, via the long-press bridge tablets already use (#1616): it watches only draggable rows inside an opted-in container, waits out a press before claiming the gesture — so a swipe stays a scroll — and replays real drag events, which is why the rows only needed the receiving half. Armed in edit mode and on coarse pointers alone, so go-mode taps and map pans are untouched and the polyfill retired in #1432 stays retired. The arrows stay: they are the accessible path. Both paths go through one mover, so the chronology guard and the undo entry are the same either way. The card header was also the only route into the day sheet, and it rendered only when the day happened to have an accommodation or a weather reading — with the accommodation chip as the sole tap target. A day with neither had no way in. A day pill now leads that row unconditionally, the weather reading is a target too, and the active day chip carries a chevron so the second tap that opens the sheet is visible in map view as well. Closes #1997 Closes #2004 --- .../screens/trip/plan/MPlanTimeline.tsx | 95 ++++++++++++-- .../screens/trip/plan/MPlanTimelineRows.tsx | 42 +++++-- .../trip/plan/useMPlanDragReorder.test.ts | 116 ++++++++++++++++++ .../screens/trip/plan/useMPlanDragReorder.ts | 105 ++++++++++++++++ .../unit/mobile/trip/MPlanTimeline.test.tsx | 16 ++- 5 files changed, 349 insertions(+), 25 deletions(-) create mode 100644 client/src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts create mode 100644 client/src/mobile/screens/trip/plan/useMPlanDragReorder.ts 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 (
+ {tl.hotelChips.map(chip => ( )}
) @@ -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 (