mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -490,3 +490,60 @@ describe('CollectionPlaceDetail', () => {
|
||||
expect(props.onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Directions (#1455) ───────────────────────────────────────────────────────
|
||||
|
||||
describe('CollectionPlaceDetail — open in maps', () => {
|
||||
const located: CollectionPlace = { ...place, lat: 48.8584, lng: 2.2945, name: 'Eiffel Tower' };
|
||||
|
||||
it('FE-COMP-COLDETAIL-036: a located place offers every map app', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ place: located });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Navigation' }));
|
||||
|
||||
const menu = await screen.findByRole('menu');
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument();
|
||||
expect(within(menu).getByRole('menuitem', { name: 'OpenStreetMap' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-037: opening a target hands the coordinates and the name over', async () => {
|
||||
const user = userEvent.setup();
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
renderDetail({ place: located });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Navigation' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Waze' }));
|
||||
|
||||
// Both, so it lands on the place rather than on a bare pin.
|
||||
const url = String(open.mock.calls[0][0]);
|
||||
expect(url).toContain('48.8584,2.2945');
|
||||
expect(url).toContain(encodeURIComponent('Eiffel Tower'));
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-038: a place with only a name and an address still opens a search', async () => {
|
||||
const user = userEvent.setup();
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
// No coordinates and no provider id — the state a place typed in by hand is
|
||||
// in. The apps that need a pin drop out; the two that can take a search
|
||||
// string are still offered.
|
||||
renderDetail();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Navigation' }));
|
||||
const menu = await screen.findByRole('menu');
|
||||
expect(within(menu).queryByRole('menuitem', { name: 'Waze' })).toBeNull();
|
||||
|
||||
await user.click(within(menu).getByRole('menuitem', { name: 'Google Maps' }));
|
||||
|
||||
expect(String(open.mock.calls[0][0])).toContain(encodeURIComponent('Test Cafe, Somewhere'));
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-039: a place with nothing to locate it offers no button at all', () => {
|
||||
renderDetail({ place: { ...place, name: '', address: null } });
|
||||
|
||||
expect(screen.queryByRole('button', { name: /navigation|google maps/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2, Navigation } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
@@ -11,6 +11,8 @@ import { NumericInput } from '../shared/NumericInput'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation'
|
||||
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
@@ -73,6 +75,9 @@ export default function CollectionPlaceDetail({
|
||||
const [editing, setEditing] = useState(false)
|
||||
const coverInputRef = useRef<HTMLInputElement>(null)
|
||||
const [imgBusy, setImgBusy] = useState(false)
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const navBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const navigationTargets = getNavigationTargets(place)
|
||||
const [name, setName] = useState(place.name)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
@@ -215,9 +220,35 @@ export default function CollectionPlaceDetail({
|
||||
|
||||
<div className="col-detail-body">
|
||||
{/* Meta (view only) */}
|
||||
{!editing && place.address && (
|
||||
{!editing && (place.address || navigationTargets.length > 0) && (
|
||||
<div className="col-detail-meta">
|
||||
<span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>
|
||||
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
|
||||
{/* Same picker as inside a trip (#1455). A saved place is somewhere
|
||||
you intend to go, and until now the only way to get directions
|
||||
was to add it to a trip first. */}
|
||||
{navigationTargets.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
ref={navBtnRef}
|
||||
type="button"
|
||||
className="col-detail-nav"
|
||||
onClick={() => {
|
||||
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
|
||||
else setNavOpen(o => !o)
|
||||
}}
|
||||
>
|
||||
<Navigation size={12} />
|
||||
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
|
||||
</button>
|
||||
{navOpen && (
|
||||
<NavigationMenu
|
||||
targets={navigationTargets}
|
||||
anchor={navBtnRef.current}
|
||||
onClose={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,38 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013c: a flight that lands the same day shows both times (#1310)', async () => {
|
||||
const sameDay = { ...transportReservation, reservation_end_time: '2025-06-01T16:45:00' }
|
||||
await downloadTripPDF({ ...richArgs, reservations: [sameDay] })
|
||||
const iframe = getIframe()
|
||||
|
||||
// Without the landing time the reader cannot tell what is left of the day.
|
||||
expect(iframe!.srcdoc).toContain('14:30 – 16:45')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013d: a flight without a landing time still shows its departure', async () => {
|
||||
await downloadTripPDF(richArgs)
|
||||
const iframe = getIframe()
|
||||
|
||||
expect(iframe!.srcdoc).toContain('14:30')
|
||||
expect(iframe!.srcdoc).not.toContain('14:30 –')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013e: an overnight flight keeps its arrival on the arrival day (#1310)', async () => {
|
||||
// Landing tomorrow: the departure day must not carry tomorrow's clock next
|
||||
// to today's departure — the arrival day shows it as its own time.
|
||||
const overnight = { ...transportReservation, day_id: 10, end_day_id: 11, reservation_end_time: '2025-06-02T06:15:00' }
|
||||
await downloadTripPDF({
|
||||
...richArgs,
|
||||
days: [dayWithPlaces, { id: 11, day_number: 2, title: null, date: '2025-06-02' } as never],
|
||||
reservations: [overnight],
|
||||
})
|
||||
const iframe = getIframe()
|
||||
|
||||
expect(iframe!.srcdoc).not.toContain('14:30 – 06:15')
|
||||
expect(iframe!.srcdoc).toContain('06:15')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => {
|
||||
await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] })
|
||||
const iframe = getIframe()
|
||||
|
||||
@@ -316,7 +316,17 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||
// Start and end, the way the day plan draws it (#1310). Without the
|
||||
// landing time a flight reads as an open-ended block and the reader
|
||||
// cannot tell how much of the day is left for anything else.
|
||||
//
|
||||
// Only on a transport that begins and ends on this day: across a
|
||||
// span the arrival belongs to the arrival day, which already shows
|
||||
// it as its own time, and repeating it here would put tomorrow's
|
||||
// clock next to today's departure.
|
||||
const startTime = splitReservationDateTime(displayTime).time ?? ''
|
||||
const endTime = phase === 'single' ? (splitReservationDateTime(r.reservation_end_time).time ?? '') : ''
|
||||
const time = [startTime, endTime].filter(Boolean).join(' – ')
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { Accommodation, Reservation } from '../../types'
|
||||
import { calculateRouteWithLegs, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||
import DayPlanSidebar from './DayPlanSidebar'
|
||||
import { makeMarkerDraggable } from '../Map/markerDrag'
|
||||
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
@@ -1022,6 +1023,31 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── Drop on day header (placeId) ───────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-199: a drag started on a map marker lands on the day (#891)', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const onAssignToDay = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAssignToDay })} />)
|
||||
|
||||
// Exactly what makeMarkerDraggable leaves behind on dragstart — the point of
|
||||
// this test is that the day plan cannot tell a marker from a sidebar row.
|
||||
const marker = document.createElement('div')
|
||||
document.body.appendChild(marker)
|
||||
makeMarkerDraggable(marker, 42)
|
||||
const dragstart = new Event('dragstart', { bubbles: true })
|
||||
const store = new Map<string, string>()
|
||||
Object.defineProperty(dragstart, 'dataTransfer', {
|
||||
value: { setData: (k: string, v: string) => store.set(k, v), getData: (k: string) => store.get(k) ?? '', effectAllowed: 'none' },
|
||||
})
|
||||
marker.dispatchEvent(dragstart)
|
||||
|
||||
const dayHeader = screen.getByText('Day 1').closest('[style*="cursor: pointer"]')
|
||||
fireEvent.drop(dayHeader as Element, { dataTransfer: { getData: (k: string) => store.get(k) ?? '' } })
|
||||
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(42, 10)
|
||||
marker.remove()
|
||||
window.__dragData = null
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-050: dropping place from sidebar onto day header calls onAssignToDay', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const onAssignToDay = vi.fn()
|
||||
@@ -1198,6 +1224,54 @@ describe('DayPlanSidebar', () => {
|
||||
expect(mockDayNotesState.saveNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
// ── Jump to today (#1567) ─────────────────────────────────────────────
|
||||
|
||||
describe('jump to today', () => {
|
||||
const isoDaysAround = (offsets: number[]) => offsets.map((o, i) => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + o)
|
||||
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
return buildDay({ id: 10 + i, date: iso, title: `Day ${i + 1}` })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-195: opening a running trip selects today rather than day one', async () => {
|
||||
const onSelectDay = vi.fn()
|
||||
const days = isoDaysAround([-1, 0, 1])
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, onSelectDay })} />)
|
||||
|
||||
await waitFor(() => expect(onSelectDay).toHaveBeenCalledWith(days[1].id, true))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-196: a trip that is not running is left alone', async () => {
|
||||
const onSelectDay = vi.fn()
|
||||
const days = isoDaysAround([5, 6, 7])
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, onSelectDay })} />)
|
||||
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-197: a day the user already picked wins over the jump', async () => {
|
||||
const onSelectDay = vi.fn()
|
||||
const days = isoDaysAround([-1, 0, 1])
|
||||
// Coming back from another tab, or in via a deep link: the selection is
|
||||
// already made and must not be overruled.
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, onSelectDay, selectedDayId: days[0].id })} />)
|
||||
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-198: a trip planned without dates has nothing to jump to', async () => {
|
||||
const onSelectDay = vi.fn()
|
||||
const days = [buildDay({ id: 1, date: null, title: 'Day 1' }), buildDay({ id: 2, date: null, title: 'Day 2' })]
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, onSelectDay })} />)
|
||||
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Note colours and formatting (#1629) ───────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-192: the note dialog offers the palette and reports the pick', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
|
||||
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
|
||||
@@ -35,6 +35,7 @@ import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||
import { noteSurface } from './noteSurface'
|
||||
import { findTodayDayId } from './today'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
|
||||
@@ -226,6 +227,39 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
} | null>(null)
|
||||
const dragDataRef = useRef(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const dayRefs = useRef<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
/** The day that is today, or null when the trip is not running right now. */
|
||||
const todayDayId = useMemo(() => findTodayDayId(days), [days])
|
||||
|
||||
const scrollToDay = useCallback((dayId: number) => {
|
||||
const el = dayRefs.current.get(dayId)
|
||||
const container = scrollContainerRef.current
|
||||
if (!el || !container) return
|
||||
// Positioned against the container, not scrollIntoView: the sidebar sits in
|
||||
// a page that also scrolls, and scrollIntoView would drag the whole layout
|
||||
// around to satisfy a scroll inside one column.
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop - 8, behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
const jumpToToday = useCallback(() => {
|
||||
if (todayDayId == null) return
|
||||
onSelectDay(todayDayId, true)
|
||||
setExpandedDays(prev => new Set(prev).add(todayDayId))
|
||||
// After the expand has rendered, or the target is measured at its old height.
|
||||
requestAnimationFrame(() => scrollToDay(todayDayId))
|
||||
}, [todayDayId, onSelectDay, scrollToDay])
|
||||
|
||||
// On opening a trip that is running, land on today rather than on day 1 (#1567).
|
||||
// Once per mount, and only while nothing is selected yet: a deep link into a
|
||||
// specific day, or a day the user picked before switching tabs, must win.
|
||||
const autoJumpedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (autoJumpedRef.current || todayDayId == null || selectedDayId != null || days.length === 0) return
|
||||
autoJumpedRef.current = true
|
||||
jumpToToday()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [todayDayId, days.length])
|
||||
useLayoutEffect(() => {
|
||||
if (scrollContainerRef.current && initialScrollTop) {
|
||||
scrollContainerRef.current.scrollTop = initialScrollTop
|
||||
@@ -1008,6 +1042,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setTimeConfirm,
|
||||
dragDataRef,
|
||||
scrollContainerRef,
|
||||
dayRefs,
|
||||
initedTransportIds,
|
||||
lastAutoScrolledIdRef,
|
||||
currency,
|
||||
@@ -1181,6 +1216,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setTimeConfirm,
|
||||
dragDataRef,
|
||||
scrollContainerRef,
|
||||
dayRefs,
|
||||
initedTransportIds,
|
||||
lastAutoScrolledIdRef,
|
||||
currency,
|
||||
@@ -1339,7 +1375,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
// The card wrapper stays untinted — its three regions (badge, header,
|
||||
// activity list) paint themselves, so a plugin controls them separately.
|
||||
<div key={day.id} title={dayTint?.label || undefined} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div key={day.id} ref={el => { if (el) dayRefs.current.set(day.id, el); else dayRefs.current.delete(day.id) }}
|
||||
title={dayTint?.label || undefined} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
className="dp-day-header"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { findTodayDayId, localToday } from './today'
|
||||
|
||||
describe('localToday', () => {
|
||||
it('FE-TODAY-001: reads the date off the local clock, not UTC', () => {
|
||||
// 08:00 in Tokyo on the 12th is still the 11th in UTC. Using toISOString()
|
||||
// here would tell a traveller in Japan that today is yesterday, every
|
||||
// morning, which is exactly who "jump to today" is for.
|
||||
const tokyoMorning = new Date(2026, 7, 12, 8, 0, 0)
|
||||
expect(localToday(tokyoMorning)).toBe('2026-08-12')
|
||||
|
||||
// And the other end: late evening must not roll over into tomorrow.
|
||||
expect(localToday(new Date(2026, 7, 12, 23, 30, 0))).toBe('2026-08-12')
|
||||
})
|
||||
|
||||
it('FE-TODAY-002: pads month and day to two digits', () => {
|
||||
expect(localToday(new Date(2026, 0, 5, 12, 0, 0))).toBe('2026-01-05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findTodayDayId', () => {
|
||||
const days = [
|
||||
{ id: 1, date: '2026-08-10' },
|
||||
{ id: 2, date: '2026-08-11' },
|
||||
{ id: 3, date: '2026-08-12' },
|
||||
]
|
||||
const at = (y: number, m: number, d: number) => new Date(y, m - 1, d, 10, 0, 0)
|
||||
|
||||
it('FE-TODAY-003: finds the day that is today', () => {
|
||||
expect(findTodayDayId(days, at(2026, 8, 11))).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-TODAY-004: is null when the trip is not running', () => {
|
||||
expect(findTodayDayId(days, at(2026, 8, 9))).toBeNull()
|
||||
expect(findTodayDayId(days, at(2026, 8, 13))).toBeNull()
|
||||
expect(findTodayDayId([], at(2026, 8, 11))).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TODAY-005: a trip planned without dates has no today to jump to', () => {
|
||||
expect(findTodayDayId([{ id: 1, date: null }, { id: 2 }], at(2026, 8, 11))).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TODAY-006: tolerates a full timestamp in the date column', () => {
|
||||
expect(findTodayDayId([{ id: 7, date: '2026-08-11T00:00:00.000Z' }], at(2026, 8, 11))).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Finding "today" inside a trip (#1567).
|
||||
*
|
||||
* Deliberately local, not UTC. `new Date().toISOString()` is the obvious way to
|
||||
* get a YYYY-MM-DD and it is wrong for exactly the people this feature is for:
|
||||
* someone in Tokyo opening the planner at 08:00 gets yesterday's date, and
|
||||
* someone in Los Angeles at 17:00 gets tomorrow's. A trip day is a calendar day
|
||||
* where the traveller is standing, so the comparison has to be made in their
|
||||
* own clock.
|
||||
*/
|
||||
|
||||
/** Today as YYYY-MM-DD in the viewer's own timezone. */
|
||||
export function localToday(now: Date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the day that is today, or null when the trip is not running.
|
||||
*
|
||||
* Days without a date (a trip planned as "day 1..7" with no calendar attached)
|
||||
* can never match, which is the intended outcome: there is nothing to jump to.
|
||||
*/
|
||||
export function findTodayDayId(days: Array<{ id: number; date?: string | null }>, now?: Date): number | null {
|
||||
const today = localToday(now)
|
||||
// Only the date part: the column is a date, but a caller could hand over an
|
||||
// ISO timestamp and a trip would silently stop having a "today".
|
||||
const match = days.find(d => typeof d.date === 'string' && d.date.slice(0, 10) === today)
|
||||
return match ? match.id : null
|
||||
}
|
||||
@@ -925,6 +925,13 @@ body {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* A marker being dragged onto a day (#891): it stays under the cursor as the
|
||||
browser's drag image, so what is left behind is dimmed to show it is in
|
||||
flight rather than still sitting there. */
|
||||
.marker-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.marker-cluster-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { findTodayDayId } from '../../../components/Planner/today'
|
||||
import {
|
||||
ChevronLeft, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck,
|
||||
Plane, Plus, Rows3, Ticket, TrainFront, Trash2, Upload, Wallet,
|
||||
@@ -205,10 +206,9 @@ export default function MTripShell({
|
||||
useEffect(() => {
|
||||
if (seededDayRef.current || planner.selectedDayId != null || days.length === 0) return
|
||||
seededDayRef.current = true
|
||||
const now = new Date()
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
const todayDay = days.find(d => d.date?.slice(0, 10) === todayStr)
|
||||
planner.tripActions.setSelectedDay((todayDay ?? days[0]).id)
|
||||
// Same rule as the desktop day plan (#1567), off the same helper so the two
|
||||
// cannot drift on what "today" means.
|
||||
planner.tripActions.setSelectedDay(findTodayDayId(days) ?? days[0].id)
|
||||
}, [planner.selectedDayId, days, planner.tripActions])
|
||||
|
||||
const trTab = planner.activeTab
|
||||
|
||||
@@ -61,6 +61,53 @@ describe('dayNotesSlice', () => {
|
||||
expect(useTripStore.getState().dayNotes['1'][0].text).toBe('Original');
|
||||
});
|
||||
|
||||
it('FE-TSLICE-NOTES-011: moving a note to another day keeps everything it carries (#1629)', async () => {
|
||||
const note = buildDayNote({ id: 10, day_id: 1, text: 'Ferry tickets', time: 'book **early**', icon: 'Ticket' });
|
||||
// buildDayNote predates note colours, so the field is set on the fixture.
|
||||
(note as unknown as { color: string }).color = '#dc2626';
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note], '2': [] } });
|
||||
|
||||
let posted: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/1/notes/:noteId', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/trips/1/days/2/notes', async ({ request }) => {
|
||||
posted = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ note: { ...note, id: 11, day_id: 2, ...posted } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().moveDayNote(1, 1, 2, 10);
|
||||
|
||||
// A move is a delete plus a create, so every field has to be re-sent — the
|
||||
// colour was the one that went missing.
|
||||
expect(posted).toMatchObject({
|
||||
text: 'Ferry tickets',
|
||||
time: 'book **early**',
|
||||
icon: 'Ticket',
|
||||
color: '#dc2626',
|
||||
});
|
||||
expect(useTripStore.getState().dayNotes['1']).toHaveLength(0);
|
||||
expect(useTripStore.getState().dayNotes['2'][0].color).toBe('#dc2626');
|
||||
});
|
||||
|
||||
it('FE-TSLICE-NOTES-012: a note without a colour moves without inventing one', async () => {
|
||||
const note = buildDayNote({ id: 10, day_id: 1, text: 'Lunch' });
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note], '2': [] } });
|
||||
|
||||
let posted: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.delete('/api/trips/1/days/1/notes/:noteId', () => HttpResponse.json({ success: true })),
|
||||
http.post('/api/trips/1/days/2/notes', async ({ request }) => {
|
||||
posted = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ note: { ...note, id: 11, day_id: 2 } });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().moveDayNote(1, 1, 2, 10);
|
||||
|
||||
expect(posted!.color).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TSLICE-NOTES-005: moveDayNote is a no-op when the note is not on the source day', async () => {
|
||||
const note = buildDayNote({ id: 10, day_id: 1 });
|
||||
seedStore(useTripStore, { dayNotes: { '1': [note], '2': [] } });
|
||||
|
||||
@@ -113,8 +113,12 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
||||
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, fromDayId, noteId)
|
||||
// Every field the note carries, not just the ones it had when this was
|
||||
// written: a move is a delete plus a create, so anything omitted here is
|
||||
// silently dropped — which is how a coloured note lost its colour on the
|
||||
// way to another day (#1629).
|
||||
const result = await dayNotesApi.create(tripId, toDayId, {
|
||||
text: note.text, time: note.time, icon: note.icon, sort_order,
|
||||
text: note.text, time: note.time, icon: note.icon, color: note.color ?? null, sort_order,
|
||||
})
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
|
||||
@@ -420,6 +420,11 @@
|
||||
.trek-dash .col-detail-catchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 15%, transparent); }
|
||||
.trek-dash .col-detail-addr { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink-3); min-width: 0; }
|
||||
.trek-dash .col-detail-addr svg { flex-shrink: 0; }
|
||||
/* Directions (#1455): sits on the meta line beside the address, quieter than
|
||||
the link chips below — it is an action on the place, not a piece of it. */
|
||||
.trek-dash .col-detail-nav { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); transition: background .12s, color .12s; white-space: nowrap; }
|
||||
.trek-dash .col-detail-nav:hover { background: var(--surface-3, var(--surface-2)); color: var(--ink); }
|
||||
.trek-dash .col-detail-nav svg { flex-shrink: 0; }
|
||||
|
||||
/* status segmented control */
|
||||
.trek-dash .col-detail-seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; border-radius: 12px; background: var(--surface-2); }
|
||||
|
||||
Reference in New Issue
Block a user