diff --git a/client/src/components/Collections/CollectionPlaceDetail.test.tsx b/client/src/components/Collections/CollectionPlaceDetail.test.tsx index 9fd473998..c57a2f933 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.test.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.test.tsx @@ -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(); + }); +}); diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 62df37d91..2684b5cc0 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -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(null) const [imgBusy, setImgBusy] = useState(false) + const [navOpen, setNavOpen] = useState(false) + const navBtnRef = useRef(null) + const navigationTargets = getNavigationTargets(place) const [name, setName] = useState(place.name) const [categoryId, setCategoryId] = useState(place.category_id ?? null) const [description, setDescription] = useState(place.description ?? '') @@ -215,9 +220,35 @@ export default function CollectionPlaceDetail({
{/* Meta (view only) */} - {!editing && place.address && ( + {!editing && (place.address || navigationTargets.length > 0) && (
- {place.address} + {place.address && {place.address}} + {/* 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 && ( + <> + + {navOpen && ( + setNavOpen(false)} + /> + )} + + )}
)} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index ab1a1dc37..2a5fcddf3 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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 ( { + 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 diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index bb1819996..eeac19e91 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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 diff --git a/client/src/components/Map/markerDrag.test.ts b/client/src/components/Map/markerDrag.test.ts new file mode 100644 index 000000000..ae347eb68 --- /dev/null +++ b/client/src/components/Map/markerDrag.test.ts @@ -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() + 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() + }) +}) diff --git a/client/src/components/Map/markerDrag.ts b/client/src/components/Map/markerDrag.ts new file mode 100644 index 000000000..91aa779e0 --- /dev/null +++ b/client/src/components/Map/markerDrag.ts @@ -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) + } +} diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 4f79d4ea5..e51717018 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -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() diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 789ee46c0..1ede6be52 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -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 `
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index c2ce18028..ff5002f8a 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -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() + + // 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() + 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() + + 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() + + 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() + + 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() + + 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 () => { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index d5da0ec50..cb21ed446 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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(null) + const dayRefs = useRef>(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. -
+
{ 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 */}
{ + 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) + }) +}) diff --git a/client/src/components/Planner/today.ts b/client/src/components/Planner/today.ts new file mode 100644 index 000000000..4fee431ae --- /dev/null +++ b/client/src/components/Planner/today.ts @@ -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 +} diff --git a/client/src/index.css b/client/src/index.css index 68e69707d..375b8a08d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 1526f7eda..b0931ed7c 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -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 diff --git a/client/src/store/slices/dayNotesSlice.test.ts b/client/src/store/slices/dayNotesSlice.test.ts index 68cb5d3e2..a3f315e82 100644 --- a/client/src/store/slices/dayNotesSlice.test.ts +++ b/client/src/store/slices/dayNotesSlice.test.ts @@ -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 | 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; + 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 | 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; + 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': [] } }); diff --git a/client/src/store/slices/dayNotesSlice.ts b/client/src/store/slices/dayNotesSlice.ts index 1b1e6dd58..1ca9578cd 100644 --- a/client/src/store/slices/dayNotesSlice.ts +++ b/client/src/store/slices/dayNotesSlice.ts @@ -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: { diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 0f98169a6..3e8f9aeba 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -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); }