mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge remote-tracking branch 'origin/dev' into dev
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