Jump to today, map-app links, drag to day, PDF landing time (#1891)

* Open a running trip on today

Asked for in discussion #1567: during a trip, opening the planner starts
at day 1 and you scroll to find out what is planned for today.

The mobile shell has done this since the plan tab was built - it seeds
the active day with today when the trip is running. The desktop day plan
never did, so the same trip behaved differently depending on the screen
you opened it on. It now selects today's day, expands it and scrolls it
into view, once per mount and only while nothing is selected yet, so a
deep link or a day picked before switching tabs still wins.

The date comparison moved into one helper both sides use. It is
deliberately local rather than toISOString(): 08:00 in Tokyo is still
yesterday in UTC, and a trip day is a calendar day where the traveller
is standing.

Also fixes a note losing its colour when dragged to another day. A move
is a delete plus a create, and the create was still sending the three
fields notes had before colours existed.

* Open a collection place in a map app

Asked for in discussion #1455: a saved place is somewhere you intend to
go, but the only way to get directions to one was to add it to a trip
first, where the button has been for a while.

The collection detail gets the same picker off the same helper: Google
Maps, Waze, Apple Maps and OpenStreetMap, filtered by what the place can
actually be located with. A place with coordinates offers all of them; a
place typed in by hand with just a name and an address still offers the
two that take a search string, and a place with neither shows no button
at all.

It sits on the meta line beside the address, quieter than the link chips
below it, because it is an action on the place rather than a piece of it.

* Drag a place off the map onto a day

Asked for in discussion #891: assigning a place means finding it again in
the sidebar, even though it is right there under the cursor on the map.

The drop half already existed and did not need touching. Every day, every
row and every gap between rows in the day plan reads a placeId and calls
onAssignToDay with the position it was dropped at, complete with the
toast and the undo entry. What was missing was a second place to pick a
place UP from - the only one was the row in the places sidebar.

Markers become that second source. Both renderers build their markers as
real DOM nodes, so one helper covers Leaflet and the GL variants alike,
and it sets the same two channels the sidebar row does: dataTransfer for
the drop, and window.__dragData because browsers hide dataTransfer while
the pointer is merely hovering. The day plan cannot tell the two apart.

Not offered on touch: HTML5 drag is a pointer feature, and on a phone
the day plan is not on screen next to the map anyway.

* Show the landing time in the PDF

Asked for in discussion #1310: the export prints a flight's departure and
nothing else, so a reader cannot tell how much of the day is left for
anything after it.

The day plan has drawn both times as "14:30 - 16:45" for a while; the
export never got the rule. Now it does, for any transport that begins and
ends on the same day - a train and a ferry read the same way a flight
does.

Across a span it stays as it was: the arrival belongs to the arrival day,
which already prints it as its own time, and repeating it on the
departure day would put tomorrow's clock next to today's departure.
This commit is contained in:
Maurice
2026-08-11 23:26:58 +02:00
committed by GitHub
parent 92b9e7548e
commit 62bc7ff8fd
17 changed files with 551 additions and 13 deletions
+18 -2
View File
@@ -3,6 +3,7 @@ import DOM from 'react-dom'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable } from './markerDrag'
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
@@ -460,17 +461,28 @@ interface MemoMarkerProps {
onClickPlace: (id: number) => void
onHover: (place: any, x: number, y: number) => void
onHoverOut: () => void
/** Off in read-only trips and on the phone, where HTML5 drag does not exist. */
draggable: boolean
}
const MemoMarker = memo(function MemoMarker({
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable,
}: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
const cleanupRef = useRef<(() => void) | null>(null)
return (
<Marker
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
// The element only exists once Leaflet has put the marker on the map,
// and it is rebuilt whenever the icon changes (selection, day number),
// so the wiring is redone on every add rather than once on mount.
add: (e: any) => {
cleanupRef.current?.()
cleanupRef.current = draggable ? makeMarkerDraggable(e.target.getElement() as HTMLElement, place.id) : null
},
remove: () => { cleanupRef.current?.(); cleanupRef.current = null },
click: () => onClickPlace(place.id),
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
@@ -681,6 +693,9 @@ export const MapView = memo(function MapView({
}, [])
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
// Drag a marker onto a day (#891). Pointer-driven, so it is off wherever
// HTML5 drag does not exist — and the day plan is not on screen there anyway.
const markersDraggable = !isTouchDevice
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
@@ -698,9 +713,10 @@ export const MapView = memo(function MapView({
onClickPlace={handleMarkerClick}
onHover={handleMarkerHover}
onHoverOut={handleMarkerHoverOut}
draggable={markersDraggable}
/>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut, markersDraggable])
// Parsing track geometry is the expensive part (tracks run to tens of thousands
// of points), so it hangs off `places` alone — a selection change must not
+8
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { makeMarkerDraggable } from './markerDrag'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
@@ -439,6 +440,9 @@ export function MapViewGL({
onClickRefs.current.context = onMapContextMenu
const hoverDisabledRef = useRef(hoverDisabled)
hoverDisabledRef.current = hoverDisabled
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
// day plan the marker would be dropped on is not on screen on a phone anyway.
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat().filter(isValidCoordinate), [route])
const routeFitKey = useMemo(
() => routeCoords.map(([lat, lng]) => `${lat.toFixed(6)},${lng.toFixed(6)}`).join('|'),
@@ -1030,6 +1034,10 @@ export function MapViewGL({
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
const selected = place.id === selectedPlaceId
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
// Drag onto a day in the plan (#891). Markers are rebuilt from scratch
// on every reconcile, so the listeners go with the element and need no
// teardown of their own.
if (markersDraggableRef.current) makeMarkerDraggable(el, place.id)
el.addEventListener('click', (ev) => {
ev.stopPropagation()
// Clear the card right away — the flyTo that follows moves the marker
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { makeMarkerDraggable } from './markerDrag'
/**
* jsdom has no drag implementation, so these drive the events the browser would
* fire. What is worth pinning is the contract the day plan reads on the other
* end: a `placeId` in dataTransfer AND on window.__dragData, because dataTransfer
* is unreadable during dragover.
*/
function dragEvent(type: string) {
const data = new Map<string, string>()
const dataTransfer = {
setData: (k: string, v: string) => { data.set(k, v) },
getData: (k: string) => data.get(k) ?? '',
effectAllowed: 'none',
}
const e = new Event(type, { bubbles: true }) as Event & { dataTransfer: typeof dataTransfer }
Object.defineProperty(e, 'dataTransfer', { value: dataTransfer })
return e
}
describe('makeMarkerDraggable', () => {
let el: HTMLElement
beforeEach(() => {
window.__dragData = null
el = document.createElement('div')
document.body.appendChild(el)
})
it('FE-MARKERDRAG-001: marks the element as a drag source', () => {
makeMarkerDraggable(el, 42)
expect(el.getAttribute('draggable')).toBe('true')
})
it('FE-MARKERDRAG-002: hands the place id over both channels', () => {
makeMarkerDraggable(el, 42)
const e = dragEvent('dragstart')
el.dispatchEvent(e)
expect(e.dataTransfer.getData('placeId')).toBe('42')
// The second one is what the day plan reads while the pointer hovers over it.
expect(window.__dragData).toEqual({ placeId: '42' })
expect(e.dataTransfer.effectAllowed).toBe('copy')
})
it('FE-MARKERDRAG-003: clears the hand-off when the drag ends', () => {
makeMarkerDraggable(el, 42)
el.dispatchEvent(dragEvent('dragstart'))
el.dispatchEvent(dragEvent('dragend'))
expect(window.__dragData).toBeNull()
expect(el.classList.contains('marker-dragging')).toBe(false)
})
it('FE-MARKERDRAG-004: dims the marker it left behind while in flight', () => {
makeMarkerDraggable(el, 7)
el.dispatchEvent(dragEvent('dragstart'))
expect(el.classList.contains('marker-dragging')).toBe(true)
})
it('FE-MARKERDRAG-005: stops mousedown from reaching the map, which would pan it away', () => {
makeMarkerDraggable(el, 7)
const onMap = vi.fn()
document.body.addEventListener('mousedown', onMap)
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
expect(onMap).not.toHaveBeenCalled()
document.body.removeEventListener('mousedown', onMap)
})
it('FE-MARKERDRAG-006: the cleanup leaves no listeners and no draggable attribute', () => {
const cleanup = makeMarkerDraggable(el, 7)
cleanup()
el.dispatchEvent(dragEvent('dragstart'))
expect(el.hasAttribute('draggable')).toBe(false)
expect(window.__dragData).toBeNull()
})
})
+50
View File
@@ -0,0 +1,50 @@
/**
* Dragging a place off the map onto a day in the itinerary (#891).
*
* The drop half of this already existed: every day, every row and every gap
* between rows in the day plan reads a `placeId` and calls onAssignToDay with
* the position it was dropped at. All that was missing was a second place to
* pick a place UP from — until now the only one was the row in the places
* sidebar. This makes a map marker behave exactly like that row.
*
* `window.__dragData` is set alongside dataTransfer on purpose, and is not
* belt-and-braces: browsers hide dataTransfer's contents during `dragover`, so
* a drop target that wants to know what is coming while it hovers has no other
* way to look. The day plan reads both (getDragData), and this mirrors what
* PlacesSidebarRow already does so the two sources are indistinguishable to it.
*
* Both renderers get the same treatment through this one helper: Leaflet builds
* its markers from divIcon HTML and the GL renderers from createElement, but
* both end up as a real DOM node, which is all HTML5 drag needs.
*/
/** Wire a marker element up as a drag source for `placeId`. */
export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => void {
el.setAttribute('draggable', 'true')
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('placeId', String(placeId))
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(placeId) }
el.classList.add('marker-dragging')
}
const onDragEnd = () => {
window.__dragData = null
el.classList.remove('marker-dragging')
}
// The map pans on mousedown anywhere on its surface, and a marker sits on that
// surface. Without this the map slides away under the pointer while the drag
// is starting, which makes the day plan an impossible target to reach.
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
el.addEventListener('dragstart', onDragStart)
el.addEventListener('dragend', onDragEnd)
el.addEventListener('mousedown', onMouseDown)
return () => {
el.removeAttribute('draggable')
el.removeEventListener('dragstart', onDragStart)
el.removeEventListener('dragend', onDragEnd)
el.removeEventListener('mousedown', onMouseDown)
}
}