Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
jubnl
2026-08-12 00:39:00 +02:00
17 changed files with 551 additions and 13 deletions
@@ -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>
)}
+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)
}
}
+32
View File
@@ -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()
+11 -1
View File
@@ -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)
})
})
+30
View File
@@ -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
}
+7
View File
@@ -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': [] } });
+5 -1
View File
@@ -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: {
+5
View File
@@ -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); }