fix(client): work through the findings from the coverage pass

214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
This commit is contained in:
Maurice
2026-07-29 11:57:24 +02:00
committed by Maurice
parent a4bf815d24
commit 72a82b3c44
425 changed files with 3155 additions and 1968 deletions
@@ -1001,6 +1001,15 @@ describe('MapView photo thumbnails', () => {
expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain('/uploads/places/mine.jpg')
})
it('FE-COMP-MAPVIEW-076: a place with neither provider id nor coordinates has no cache key and is skipped', () => {
render(<MapView places={[
buildMapPlace({ id: 25, lat: null, lng: null, image_url: 'https://example.com/a.jpg' }),
buildMapPlace({ id: 26, lat: null, lng: null, image_url: 'https://example.com/b.jpg' }),
]} />)
expect(thumbCallbacks.size).toBe(0)
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-071: photos are not fetched at all when the feature is off', () => {
useAuthStore.setState({ placesPhotosEnabled: false })
render(<MapView places={[buildMapPlace({ id: 24, lat: 48, lng: 2, google_place_id: 'gp-24' })]} />)
+7 -22
View File
@@ -36,15 +36,6 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
})
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function escAttr(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const iconCache = new Map<string, L.DivIcon>()
// Tone dot for a plugin route's via points (charging stops, rest areas) — smaller
@@ -73,6 +64,10 @@ function formatViaDwell(seconds: number): string {
return h > 0 ? `${h} h ${m} min` : `${m} min`
}
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
const cached = iconCache.get(cacheKey)
@@ -329,16 +324,6 @@ interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
const map = useMap()
useEffect(() => {
map.on('zoomstart', onZoomStart)
map.on('zoomend', onZoomEnd)
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
}, [map, onZoomStart, onZoomEnd])
return null
}
const TRACK_CASING_PANE = 'trek-track-casing'
/**
@@ -385,7 +370,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage } from './placePhoto'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
@@ -647,7 +632,7 @@ export const MapView = memo(function MapView({
// photo for it (the request would 404 for OSM-only places and the fetched
// thumb would shadow the user's own image). (#1136)
if (isCustomPlaceImage(place.image_url)) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const cacheKey = photoCacheKey(place)
if (!cacheKey) continue
const cached = getCached(cacheKey)
@@ -693,7 +678,7 @@ export const MapView = memo(function MapView({
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const pck = photoCacheKey(place)
// A custom uploaded image wins over the auto-fetched thumb; otherwise fall back.
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
const orderNumbers = dayOrderMap[place.id] ?? null
@@ -909,6 +909,29 @@ describe('MapViewGL', () => {
expect(queryByTestId('tooltip')).toBeNull()
})
it('FE-COMP-MAPVIEWGL-067: hovering does not rebuild the markers under the cursor (#1404)', async () => {
loadOnAttach()
const places = [
buildMapPlace({ id: 41, lat: 48.41, lng: 2.41 }),
buildMapPlace({ id: 42, lat: 48.42, lng: 2.42 }),
]
const { queryByTestId } = render(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const before = glMarkers.created.length
const el = glMarkers.created[0].element
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
await act(async () => {})
// The hover card is component state, so it re-renders MapViewGL. The omitted
// collection props have to keep their identity across that render — a fresh
// `[]`/`{}` default would re-run the reconcile effects and recreate every
// marker, and a marker recreated under the pointer never fires mouseleave.
expect(queryByTestId('tooltip')).toBeTruthy()
expect(glMarkers.created).toHaveLength(before)
})
it('FE-COMP-MAPVIEWGL-027: clicking a GPX track selects its place unless a marker or cluster is on top', async () => {
loadOnAttach()
const onMarkerClick = vi.fn()
@@ -1588,6 +1611,17 @@ describe('MapViewGL', () => {
expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum')
})
it('FE-COMP-MAPVIEWGL-066: a place with neither provider id nor coordinates has no cache key and is skipped', async () => {
render(<MapViewGL places={[
buildMapPlace({ id: 98, lat: null, lng: null, image_url: 'https://example.com/a.jpg' }),
buildMapPlace({ id: 99, lat: null, lng: null, image_url: 'https://example.com/b.jpg' }),
]} fitKey={1} />)
await act(async () => {})
expect(photoService.fetchPhoto).not.toHaveBeenCalled()
expect(photoService.onThumbReady).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-057: no photos are fetched when place photos are switched off', async () => {
useAuthStore.setState({ placesPhotosEnabled: false })
+28 -14
View File
@@ -7,7 +7,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage } from './placePhoto'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
@@ -73,6 +73,20 @@ interface RouteSegment {
drivingText?: string
}
// Stable identities for the omitted collection props. An inline `= []` / `= {}`
// default allocates a fresh object on every render, and these props sit in the
// dependency arrays of the imperative reconcile effects below — so every render,
// including one caused only by the hover tooltip's state, would tear down and
// rebuild every marker. That is the "marker recreated under the cursor,
// mouseleave never fires" case (#1404).
const NO_PLACES: Place[] = []
const NO_ROUTE_VIAS: RouteVia[] = []
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
const NO_DAY_ORDER: Record<number, number[] | null> = {}
const NO_RESERVATIONS: Reservation[] = []
const NO_CONNECTION_IDS: number[] = []
const NO_POIS: Poi[] = []
interface Props {
places: Place[]
dayPlaces?: Place[]
@@ -315,12 +329,12 @@ function createPoiMarkerElement(category: string): HTMLDivElement {
}
export function MapViewGL({
places = [],
dayPlaces = [],
places = NO_PLACES,
dayPlaces = NO_PLACES,
tripId,
routeVias = [],
routeVias = NO_ROUTE_VIAS,
route = null,
routeSegments = [],
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
@@ -329,17 +343,17 @@ export function MapViewGL({
center = DEFAULT_MAP_CENTER,
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
dayOrderMap = {},
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
hasDayDetail = false,
reservations = [],
visibleConnectionIds = [],
reservations = NO_RESERVATIONS,
visibleConnectionIds = NO_CONNECTION_IDS,
showTransitRoutes = true,
showReservationStats = false,
onReservationClick,
pois = [],
pois = NO_POIS,
onPoiClick,
onViewportChange,
glProvider = 'mapbox-gl',
@@ -816,12 +830,12 @@ export function MapViewGL({
if (ev.touches.length !== 1) { cancelLongPress(); return }
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
const t = ev.touches[0]
lpStart = { x: t.clientX, y: t.clientY }
const start = { x: t.clientX, y: t.clientY }
lpStart = start
lpTimer = window.setTimeout(() => {
lpTimer = null
if (!lpStart) return
const rect = canvas.getBoundingClientRect()
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
const lngLat = map.unproject([start.x - rect.left, start.y - rect.top])
lpStart = null
// Only suppress the tap when OUR fire opened the form — if the native
// contextmenu beat us to it (dedupe), no click needs swallowing.
@@ -945,7 +959,7 @@ export function MapViewGL({
// photo for it (that request would 404 for OSM-only places and, worse, the
// fetched thumb would shadow the user's own image). (#1136)
if (isCustomPlaceImage(place.image_url)) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const cacheKey = photoCacheKey(place)
if (!cacheKey) continue
const cached = getCached(cacheKey)
if (cached?.thumbDataUrl) {
@@ -1002,7 +1016,7 @@ export function MapViewGL({
visiblePlaces.forEach(place => {
const orderNumbers = dayOrderMap[place.id] ?? null
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const pck = photoCacheKey(place)
// A custom image wins over the auto-fetched thumb; otherwise fall back to it.
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
const selected = place.id === selectedPlaceId
@@ -0,0 +1,60 @@
// FE-COMP-POIPILL-001 to FE-COMP-POIPILL-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import PoiCategoryPill from './PoiCategoryPill'
import { POI_CATEGORIES } from './poiCategories'
const CAFE = POI_CATEGORIES.find(c => c.key === 'cafe')!
const BAR = POI_CATEGORIES.find(c => c.key === 'bar')!
function pill(props: Partial<React.ComponentProps<typeof PoiCategoryPill>> = {}) {
return render(
<PoiCategoryPill active={new Set()} onToggle={vi.fn()} {...props} />,
)
}
// The spinner replaces the category icon, so "is this segment spinning" reads as
// "does its button hold an .animate-spin element".
const spinning = () => screen.getAllByRole('button')
.filter(b => b.querySelector('.animate-spin'))
.map(b => b.getAttribute('aria-label'))
describe('PoiCategoryPill', () => {
it('FE-COMP-POIPILL-001: renders one segment per category, none pressed', () => {
pill()
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(POI_CATEGORIES.length)
expect(buttons.every(b => b.getAttribute('aria-pressed') === 'false')).toBe(true)
})
it('FE-COMP-POIPILL-002: clicking a segment toggles its category', () => {
const onToggle = vi.fn()
pill({ onToggle })
fireEvent.click(screen.getAllByRole('button')[1])
expect(onToggle).toHaveBeenCalledWith(POI_CATEGORIES[1].key)
})
it('FE-COMP-POIPILL-003: the active category spins while its fetch is in flight', () => {
pill({ active: new Set([CAFE.key]), loadingKeys: new Set([CAFE.key]) })
expect(spinning()).toEqual(['Cafés'])
})
it('FE-COMP-POIPILL-004: a deselected category never spins, even with a lingering loading key', () => {
pill({ active: new Set([BAR.key]), loadingKeys: new Set([CAFE.key, BAR.key]) })
expect(spinning()).toEqual(['Bars & nightlife'])
})
it('FE-COMP-POIPILL-005: a failed category offers a retry', () => {
pill({ active: new Set([CAFE.key]), errorKeys: new Set([CAFE.key]) })
expect(screen.getByText('Search this area')).toBeInTheDocument()
})
it('FE-COMP-POIPILL-006: "search this area" reports the moved viewport', () => {
const onSearchArea = vi.fn()
pill({ active: new Set([CAFE.key]), moved: true, onSearchArea })
fireEvent.click(screen.getByText('Search this area'))
expect(onSearchArea).toHaveBeenCalled()
})
})
@@ -33,7 +33,9 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKe
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
{POI_CATEGORIES.map(cat => {
const on = active.has(cat.key)
const loading = loadingKeys?.has(cat.key)
// Only an active category can be loading — a deselected one whose fetch
// is still winding down must not keep spinning.
const loading = on && !!loadingKeys?.has(cat.key)
return (
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
<button
+10 -182
View File
@@ -1,4 +1,4 @@
import { Fragment, createElement, useEffect, useMemo, useRef, useState } from 'react'
import { Fragment, createElement, useMemo, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
@@ -10,8 +10,6 @@ import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
const AIRPORT_BADGE_HALF_PX = 16
const BADGE_GAP_PX = 5
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
@@ -46,7 +44,7 @@ function useEndpointPane() {
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span>${escapeHtml(label)}</span>` : ''
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
return L.divIcon({
className: 'trek-endpoint-marker',
@@ -57,7 +55,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
border:1.5px solid #fff;color:#fff;
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''}</div>`,
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`,
iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11],
@@ -135,182 +133,18 @@ interface TransportItem {
type: TransportType
arcs: [number, number][][]
transitSegs: TransitMapSegment[]
primaryArc: [number, number][]
fallback: [number, number]
// Route ("VIE → LHR") and duration/distance line. Computed on every update but
// not drawn since the stats badge was dropped; computeDuration still guards the
// non-finite date that used to blank the trip (#1620).
mainLabel: string | null
subLabel: string | null
}
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${color}aa;
font-family:var(--font-system);
white-space:nowrap;box-sizing:border-box;
transform-origin:center;
will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
function StatsLabel({ item }: { item: TransportItem }) {
const map = useMap()
const markerRef = useRef<L.Marker | null>(null)
const innerRef = useRef<HTMLElement | null>(null)
const arc = item.primaryArc
const color = TYPE_META[item.type].color
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
const compute = () => {
if (arc.length < 2) return null
const size = map.getSize()
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
const cum: number[] = [0]
let total = 0
for (let i = 1; i < pts.length; i++) {
total += pts[i].distanceTo(pts[i - 1])
cum.push(total)
}
if (total <= 0) return null
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const isIn = (p: L.Point) => {
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
if (p.distanceTo(fromPx) < buffer) return false
if (p.distanceTo(toPx) < buffer) return false
return true
}
let firstIdx = -1
let lastIdx = -1
for (let i = 0; i < pts.length; i++) {
if (isIn(pts[i])) {
if (firstIdx < 0) firstIdx = i
lastIdx = i
}
}
if (firstIdx < 0) {
const target = total / 2
let sIdx = 0
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
const span = cum[sIdx + 1] - cum[sIdx]
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
const pA = pts[sIdx]
const pB = pts[sIdx + 1]
const mx = pA.x + (pB.x - pA.x) * tm
const my = pA.y + (pB.y - pA.y) * tm
const latlng = map.containerPointToLatLng([mx, my])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const bisectFraction = (a: L.Point, b: L.Point) => {
let lo = 0, hi = 1
for (let k = 0; k < 10; k++) {
const mid = (lo + hi) / 2
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
if (isIn(mp)) hi = mid
else lo = mid
}
return (lo + hi) / 2
}
let lowCum = cum[firstIdx]
if (firstIdx > 0) {
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
}
let highCum = cum[lastIdx]
if (lastIdx < pts.length - 1) {
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
}
const targetLen = (lowCum + highCum) / 2
let segIdx = 0
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
const segSpan = cum[segIdx + 1] - cum[segIdx]
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
const pA = pts[segIdx]
const pB = pts[segIdx + 1]
const px = pA.x + (pB.x - pA.x) * t
const py = pA.y + (pB.y - pA.y) * t
const latlng = map.containerPointToLatLng([px, py])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const apply = () => {
const pose = compute()
const marker = markerRef.current
if (!marker) return
const el = marker.getElement() as HTMLElement | null
if (!pose) {
if (el) el.style.display = 'none'
return
}
if (el) el.style.display = ''
marker.setLatLng(pose.point as L.LatLngTuple)
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
}
useEffect(() => {
const icon = L.divIcon({
className: 'trek-endpoint-stats',
html,
iconSize: [width, height],
iconAnchor: [width / 2, height / 2],
})
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
marker.addTo(map)
markerRef.current = marker
innerRef.current = null
apply()
return () => {
marker.remove()
markerRef.current = null
innerRef.current = null
}
}, [map, html, width, height])
useMapEvents({
move: apply,
zoom: apply,
viewreset: apply,
resize: apply,
})
return null
}
interface Props {
reservations: Reservation[]
showConnections: boolean
// Accepted for call-site compatibility only: the floating route/duration badge
// on the arc is no longer drawn, so nothing is gated on this.
showStats: boolean
onEndpointClick?: (reservationId: number) => void
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
@@ -318,7 +152,7 @@ interface Props {
roadRoutes?: Map<number, [number, number][]>
}
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick, roadRoutes }: Props) {
export default function ReservationOverlay({ reservations, showConnections, onEndpointClick, roadRoutes }: Props) {
useEndpointPane()
const map = useMap()
const [zoom, setZoom] = useState(() => map.getZoom())
@@ -354,12 +188,6 @@ export default function ReservationOverlay({ reservations, showConnections, show
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km`
// Show the full route (FRA → BER → HND) when every waypoint has a code.
@@ -369,7 +197,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], primaryArc, fallback, mainLabel, subLabel })
out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], mainLabel, subLabel })
}
return out
}, [reservations])
+18
View File
@@ -8,3 +8,21 @@
export function isCustomPlaceImage(url: string | null | undefined): boolean {
return !!url && (url.startsWith('/uploads/') || url.startsWith('data:'))
}
/**
* Cache key under which a place's auto-fetched thumbnail is stored: the provider
* id when there is one, otherwise the coordinates. A place with neither has no
* identity to cache under, so it gets the empty string — callers skip it rather
* than filing every coordinate-less place under one shared entry.
*/
export function photoCacheKey(place: {
google_place_id?: string | null
osm_id?: string | null
lat?: number | null
lng?: number | null
}): string {
if (place.google_place_id) return place.google_place_id
if (place.osm_id) return place.osm_id
if (place.lat == null || place.lng == null) return ''
return `${place.lat},${place.lng}`
}
@@ -264,7 +264,6 @@ describe('ReservationMapboxOverlay lifecycle', () => {
expect(glMarkers.every(m => m.remove.mock.calls.length > 0)).toBe(true)
expect(map.listeners('zoomend')).toBe(0)
expect(map.listeners('moveend')).toBe(0)
expect(map.listeners('render')).toBe(0)
expect(map.removeLayer).toHaveBeenCalledWith(RESERVATION_LINE_LAYER_ID)
expect(map.removeSource).toHaveBeenCalledWith(RESERVATION_SOURCE_ID)
})
@@ -287,20 +286,18 @@ describe('ReservationMapboxOverlay lifecycle', () => {
expect(features(map)).toHaveLength(1)
})
it('FE-COMP-RESGL-006: the redraw and rotation handlers are inert once the overlay is destroyed', () => {
it('FE-COMP-RESGL-006: the redraw handler is inert once the overlay is destroyed', () => {
const map = freshMap()
const overlay = attach(map)
const handlerFor = (event: string) =>
map.on.mock.calls.find(c => c[0] === event)?.[1] as Handler
const rerender = handlerFor('moveend')
const rotate = handlerFor('render')
overlay.update([booking()], opts)
const drawsBefore = map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls.length
overlay.destroy()
rerender()
rotate()
expect(map._sources.get(RESERVATION_SOURCE_ID)).toBeUndefined()
expect(drawsBefore).toBeGreaterThan(0)
})
@@ -700,7 +697,7 @@ describe('ReservationMapboxOverlay transit styling', () => {
})
describe('ReservationMapboxOverlay frame handler', () => {
it('FE-COMP-RESGL-042: a paint frame rotates only — it does not redraw the overlay', () => {
it('FE-COMP-RESGL-042: the overlay does not subscribe to paint frames — only camera events redraw it', () => {
const map = freshMap()
const overlay = attach(map)
overlay.update([booking()], opts)
@@ -708,6 +705,7 @@ describe('ReservationMapboxOverlay frame handler', () => {
map.fire('render')
expect(map.listeners('render')).toBe(0)
expect(map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls).toHaveLength(drawsBefore)
expect(glMarkers).toHaveLength(2)
expect(glMarkers.every(m => m.remove.mock.calls.length === 0)).toBe(true)
@@ -102,7 +102,9 @@ interface TransportItem {
waypoints: ReservationEndpoint[]
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
// Route ("VIE → LHR") and duration/distance line. Computed on every update but
// not drawn since the stats badge was dropped; computeDuration still guards the
// non-finite date that used to blank the trip (#1620).
mainLabel: string | null
subLabel: string | null
}
@@ -136,8 +138,6 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km`
const mainLabel = waypoints.every(w => w.code)
@@ -145,7 +145,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel })
out.push({ res: r, from, to, waypoints, type, arcs, mainLabel, subLabel })
}
return out
}
@@ -165,32 +165,11 @@ function endpointMarkerHtml(type: TransportType, label: string | null): string {
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
}
function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${TRANSPORT_COLOR}aa;
font-family:var(--font-system);
white-space:nowrap;box-sizing:border-box;pointer-events:none;
transform-origin:center;will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
// ── overlay manager ──────────────────────────────────────────────────────
export interface ReservationOverlayOptions {
showConnections: boolean
// Accepted for call-site compatibility only: the floating route/duration badge
// on the arc is no longer drawn, so nothing is gated on this.
showStats: boolean
showEndpointLabels: boolean
onEndpointClick?: (reservationId: number) => void
@@ -212,7 +191,6 @@ export class ReservationMapboxOverlay {
private opts: ReservationOverlayOptions
private MarkerCtor: MarkerConstructor
private endpointMarkers: GlMarker[] = []
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
@@ -224,7 +202,6 @@ export class ReservationMapboxOverlay {
this.setupLayer()
map.on('zoomend', this.rerender)
map.on('moveend', this.rerender)
map.on('render', this.updateStatsRotation)
}
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
@@ -238,11 +215,8 @@ export class ReservationMapboxOverlay {
this.destroyed = true
this.map.off('zoomend', this.rerender)
this.map.off('moveend', this.rerender)
this.map.off('render', this.updateStatsRotation)
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
try {
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
@@ -386,34 +360,7 @@ export class ReservationMapboxOverlay {
}
}
// Stats badge removed — the floating route/duration label on the arc is no
// longer drawn; only the connection line and the airport markers remain.
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
}
// Match the Leaflet overlay's "rotate the label along the arc" look.
// We pick a short segment straddling the arc midpoint, measure the
// screen angle between those two projected points, and clamp it to
// [-90°, 90°] so text never renders upside-down.
private updateStatsRotation = () => {
if (this.destroyed) return
for (const entry of this.statsMarkers) {
const { marker, arc } = entry
if (arc.length < 2) continue
const midIdx = Math.floor(arc.length / 2)
const a = arc[Math.max(0, midIdx - 2)]!
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
try {
const pa = this.map.project([a[1], a[0]])
const pb = this.map.project([b[1], b[0]])
let angle = Math.atan2(pb.y - pa.y, pb.x - pa.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
const el = marker.getElement()
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (inner) inner.style.transform = `rotate(${angle}deg)`
} catch { /* map not ready / projection failure */ }
}
// No stats badge: the floating route/duration label on the arc was removed,
// so a rendered overlay is the connection lines plus the airport markers.
}
}
@@ -1,4 +1,4 @@
// FE-COMP-POIEXPLORE-001 to FE-COMP-POIEXPLORE-016
// FE-COMP-POIEXPLORE-001 to FE-COMP-POIEXPLORE-018
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
@@ -240,4 +240,34 @@ describe('usePoiExplore', () => {
expect(result.current.errorKeys.size).toBe(0)
expect(result.current.pois[0].osm_id).toBe('node/9')
})
it('FE-COMP-POIEXPLORE-017: the abandoned category stops loading when the selection moves on', async () => {
const first = deferred<PoiResponse>()
pois.mockReturnValueOnce(first.promise)
const { result } = setup()
act(() => { result.current.onViewportChange(BBOX) })
act(() => { result.current.toggle('cafe') })
expect(result.current.loadingKeys.has('cafe')).toBe(true)
pois.mockResolvedValue(response([poi({ osm_id: 'node/9', category: 'bar' })]))
act(() => { result.current.toggle('bar') })
await act(async () => { first.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' })) })
await waitFor(() => expect(result.current.loadingKeys.has('cafe')).toBe(false))
expect(result.current.loadingKeys.has('bar')).toBe(false)
})
it('FE-COMP-POIEXPLORE-018: turning the category off stops its spinner as well', async () => {
const d = deferred<PoiResponse>()
pois.mockReturnValue(d.promise)
const { result } = setup()
act(() => { result.current.onViewportChange(BBOX) })
act(() => { result.current.toggle('cafe') })
act(() => { result.current.toggle('cafe') })
await act(async () => { d.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' })) })
await waitFor(() => expect(result.current.loadingKeys.size).toBe(0))
})
})
@@ -74,6 +74,11 @@ export function usePoiExplore() {
if (abortRef.current[key] === ctrl) {
setLoading(key, false)
delete abortRef.current[key]
} else if (!abortRef.current[key]) {
// Cancelled with nothing taking over (toggle switched the category, or
// turned it off) — no later request will clear this key, so do it here
// instead of leaving the pill spinning forever.
setLoading(key, false)
}
}
}, [setLoading, setError, locale])