mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c735c80a4 | |||
| a8e4c45576 | |||
| 87c73e50fb | |||
| 1128bcd1c5 | |||
| 7946c2f749 | |||
| 5ef60f5641 | |||
| 9ce8b7ea9e | |||
| a9907e2f10 | |||
| a00cc889ed | |||
| 3f0eb210d8 | |||
| bd6e6312e1 | |||
| 96ab748f34 | |||
| aaf6559d5d | |||
| 5b147af0b6 | |||
| 95d1752a3d | |||
| c9e0fe52d5 | |||
| bf1900c3a0 | |||
| 59b7c35e87 | |||
| 77162aa8d5 | |||
| 29fc586930 | |||
| 7ba7338f5f | |||
| 2f71549413 | |||
| fab3d2bdf8 | |||
| 00cac02665 | |||
| 756e0fb908 | |||
| d30b916f9c | |||
| 88129e4546 | |||
| a46d3fe88a | |||
| 149b87fd47 | |||
| 15beb0ea40 | |||
| f283254bb0 | |||
| 8acfaea3c4 | |||
| a463246c1c | |||
| 2a38e79431 | |||
| 2d2f1e88bd | |||
| 1e2a5e3862 | |||
| 7e3b9fd039 | |||
| 07bb899c50 | |||
| 9543fdc3f2 | |||
| 59a1756529 | |||
| 4611f0ab7a | |||
| 6a3d4b1281 | |||
| 06ecc5135c | |||
| b2a196d1e7 | |||
| f5d6af9c77 | |||
| 9fa0ceaf49 | |||
| 900e4806ad | |||
| 4a88dbcbcf | |||
| 1761f2bcd5 | |||
| 8be594d34c | |||
| 5ac6ce40be | |||
| 2d7889c237 | |||
| cfae0a1f78 | |||
| 31061751b0 | |||
| 559b7624d0 | |||
| 2de9de8c3a | |||
| d609aefe8f | |||
| 1792fc17d0 | |||
| 72935d7e40 | |||
| 569f0ca1a5 | |||
| 5d0d106c70 | |||
| c04e740ff1 | |||
| 515398f8ee | |||
| e4f2eaf336 | |||
| 16fee0800d | |||
| 888cd4ee08 | |||
| 8fe90d1783 | |||
| 4cda3c4b2e | |||
| bc85ec1eea | |||
| 04c22b34af | |||
| 56a23cb809 | |||
| 251378be1d | |||
| 973ce5f8ff | |||
| bbc32310e9 | |||
| ad6e51c574 | |||
| 6da11994d9 | |||
| eef1f16836 | |||
| 4a8d77c59c | |||
| a70c2eb9e0 | |||
| 99b040f684 | |||
| 944e0a0970 | |||
| b9f69b0c76 | |||
| fcefc7e771 | |||
| 6f0a171c74 | |||
| 3e1c06a0ee | |||
| 1aafab83a5 | |||
| c1d0435480 | |||
| 798859517f | |||
| d3fdf78db6 | |||
| 1b71b8633d | |||
| d77f1c1758 | |||
| e09c0688ae | |||
| 0e66ae72ca | |||
| 61bef334d5 | |||
| fa406b708f | |||
| 09ce5cb733 |
@@ -387,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
|
||||
)
|
||||
|
||||
// Appended, not interleaved: the core list keeps the order it earned.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
|
||||
source: 'openstreetmap+plugin:demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
// The optional index being unwell must never cost the search that worked.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }],
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
|
||||
|
||||
@@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => {
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
/**
|
||||
* The one call in this file that is deliberately TWO requests, which is why it is not
|
||||
* in the list above: a search asks TREK's own indexes and any installed search-provider
|
||||
* plugin at the same time, and the caller gets one list back (#2221).
|
||||
*/
|
||||
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
|
||||
log = []
|
||||
await mapsApi.search('Rome')
|
||||
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
|
||||
'GET /api/plugin-search',
|
||||
'POST /api/maps/search',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
|
||||
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
|
||||
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
|
||||
|
||||
@@ -661,8 +661,6 @@ export const adminApi = {
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
|
||||
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
|
||||
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
|
||||
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
@@ -1145,6 +1143,50 @@ async function withCachedPlaces<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What plugins implementing `searchProvider` found for the same query (#2221).
|
||||
*
|
||||
* Its own request beside the core one rather than a branch inside it: the core search
|
||||
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
|
||||
* answers with an empty list for every failure there is — no provider installed, one
|
||||
* that timed out, a 404 on an older server, the network gone — because a search that
|
||||
* breaks when an optional index is unwell is worse than one without it.
|
||||
*/
|
||||
/**
|
||||
* How long a plugin index may keep the search list waiting, in milliseconds.
|
||||
*
|
||||
* The host gives a provider two seconds to answer and this gives the round trip a
|
||||
* little more. It is a ceiling on the WAIT, not on the provider: with no search
|
||||
* plugin installed the route answers immediately without reaching any of them, so
|
||||
* the normal case costs one local round trip and nothing else.
|
||||
*
|
||||
* A deadline rather than patience, because the alternative is a search that feels
|
||||
* broken. A list that arrives without an optional index is a smaller answer; a list
|
||||
* that arrives four seconds late is no answer at all.
|
||||
*/
|
||||
const PLUGIN_SEARCH_DEADLINE_MS = 2500
|
||||
|
||||
async function pluginSearchPlaces(
|
||||
query: string,
|
||||
lang?: string,
|
||||
near?: { lat: number; lng: number },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const stop = new AbortController()
|
||||
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
|
||||
try {
|
||||
const r = await apiClient.get('/plugin-search', {
|
||||
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
|
||||
signal: stop.signal,
|
||||
})
|
||||
const places = (r.data as { places?: unknown })?.places
|
||||
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
@@ -1154,10 +1196,25 @@ export const mapsApi = {
|
||||
* It also decides whether the index answers at all: a common single word
|
||||
* without coordinates is refused upstream as too expensive, and the search
|
||||
* then falls back to Nominatim alone.
|
||||
*
|
||||
* Plugin indexes are asked at the same time and their hits are appended, so every
|
||||
* caller of this one function gets them without knowing they exist. Appended rather
|
||||
* than interleaved: the core list is ordered by relevance and has earned that order,
|
||||
* and a plugin's row carries its own `source` for a caller that wants to mark it.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
// Side by side, so the wait is the slower of the two rather than their sum. Only
|
||||
// the core call may reject: that rejection is what hands withCachedPlaces the
|
||||
// offline path, and a plugin failure must never trigger it.
|
||||
const [core, extra] = await Promise.all([
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
pluginSearchPlaces(query, lang, locationBias),
|
||||
])
|
||||
if (extra.length === 0) return core
|
||||
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
|
||||
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
|
||||
}),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
withCachedPlaces(
|
||||
input,
|
||||
@@ -1384,13 +1441,19 @@ export const collabApi = {
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
|
||||
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
|
||||
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
|
||||
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
|
||||
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
|
||||
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
/**
|
||||
* Reports the routing counters this browser has tallied up.
|
||||
*
|
||||
* Fire and forget: it never throws and never blocks the route it is describing. A
|
||||
* failed report is a lost count, which is a far smaller problem than a map that waits
|
||||
* on a measurement.
|
||||
*
|
||||
* No client-side switch to check, unlike the shadow log. Counting is on unless an
|
||||
* operator turned it off, and that is a server setting the server enforces — a report
|
||||
* to a switched-off instance is answered with `recorded: false` and costs one request.
|
||||
*/
|
||||
export const routeUsageApi = {
|
||||
report: (entries: RouteUsageEntry[]): Promise<void> =>
|
||||
entries.length
|
||||
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
|
||||
/** Admin only. The figures behind "could we host a router ourselves". */
|
||||
summary: (): Promise<RouteUsageSummaryResult> =>
|
||||
apiClient.get('/route-usage/summary').then(r => r.data),
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
'collab:note:deleted',
|
||||
'collab:link:created',
|
||||
'collab:link:updated',
|
||||
'collab:link:deleted',
|
||||
'collab:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
@@ -27,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
|
||||
// routed through live in that hook and nowhere else; a store slice for them would be a
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
|
||||
@@ -74,11 +74,12 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
@@ -262,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -44,6 +44,7 @@ type Defaults = {
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -121,6 +122,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [valhallaBase, setValhallaBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -131,6 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setValhallaBase(data.valhalla_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -154,6 +157,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'valhalla_base_url') setValhallaBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -223,11 +227,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
<div>
|
||||
<p className="text-sm text-content-faint mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
stayed empty. Grouped by subject rather than by height — what a user ends up
|
||||
seeing on the left, everything that configures the map on the right.
|
||||
The columns are deliberately uneven: the left one never needs more than the
|
||||
widest option row, while the right one holds four text fields whose values
|
||||
are 60-character tile URLs. Only the column gap is set, because Section
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
@@ -325,7 +341,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
@@ -395,6 +413,28 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* The second engine, next to the first because it is the same kind of decision
|
||||
and carries the same restart caveat. Only ever asked to leave a road class
|
||||
out of one leg, which is the one question the OSRM above refuses. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.valhallaBase')}
|
||||
<ResetButton field="valhalla_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={valhallaBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
|
||||
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
|
||||
placeholder="https://valhalla.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
@@ -520,6 +560,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,10 +8,11 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -19,7 +20,8 @@ import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
@@ -84,6 +86,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
// One note open at a time: two expanded rows next to each other read as a mess,
|
||||
// and the point of the collapse is that the list stays scannable.
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
|
||||
@@ -492,6 +495,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
{previewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={previewReceipts.receipts}
|
||||
initialIndex={previewReceipts.initialIndex}
|
||||
onClose={() => setPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||
@@ -762,6 +773,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{t('costs.unfinished')}
|
||||
</span>
|
||||
)}
|
||||
{(e.receipts || []).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
|
||||
}}
|
||||
title={t('costs.viewReceipt')}
|
||||
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Receipt size={12} className="text-content-muted" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
@@ -1122,6 +1159,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
return m
|
||||
})
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts(prev => prev.filter(r => r.id !== receiptId))
|
||||
}
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
@@ -1318,12 +1373,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
))
|
||||
// Only cleared once the save went through, so a retry after a failure
|
||||
// does not upload a second copy of every file.
|
||||
setPendingReceiptFiles([])
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('common.unknownError'))
|
||||
} catch (err) {
|
||||
// A receipt the rollback could not remove is still on the trip, and the
|
||||
// user is the only one who can clear it out of the Files tab.
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||
} finally {
|
||||
setUploadingReceipt(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -1633,8 +1699,91 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
|
||||
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
<div className={panelCls}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
handleReceiptFileSelect(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
|
||||
<Plus size={13} /> {t('costs.attachReceipt')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploadingReceipt && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
|
||||
{t('common.saving')}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
|
||||
{t('costs.noReceipts')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{receipts.map((r, rIdx) => (
|
||||
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
|
||||
className="text-content hover:underline"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<Receipt size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveReceipt(r.id)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingReceiptFiles.map((file, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePendingReceipt(idx)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalPreviewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={modalPreviewReceipts.receipts}
|
||||
initialIndex={modalPreviewReceipts.initialIndex}
|
||||
onClose={() => setModalPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
|
||||
// on top of the expense form.
|
||||
import { vi } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
|
||||
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
|
||||
|
||||
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
|
||||
return {
|
||||
id: 1,
|
||||
filename: 'stored.jpg',
|
||||
original_name: 'lunch.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 100,
|
||||
url: '/uploads/files/stored.jpg',
|
||||
...over,
|
||||
} as BudgetItemReceipt
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
|
||||
})
|
||||
|
||||
describe('ReceiptPreviewModal', () => {
|
||||
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
|
||||
// The viewer renders through a portal, so it lives on document.body.
|
||||
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
|
||||
authUrl.get.mockRejectedValue(new Error('403'))
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
|
||||
const onClose = vi.fn()
|
||||
const parent = vi.fn()
|
||||
document.addEventListener('keydown', parent)
|
||||
try {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// The expense form registers its own Escape handler on `document`; one
|
||||
// keypress must not close both and throw away the user's edits.
|
||||
expect(parent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
document.removeEventListener('keydown', parent)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
|
||||
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
interface ReceiptPreviewModalProps {
|
||||
receipts: BudgetItemReceipt[]
|
||||
initialIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
|
||||
const [signedUrl, setSignedUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const current = receipts[index]
|
||||
const isImg = isImage(current?.mime_type)
|
||||
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSignedUrl('')
|
||||
|
||||
getAuthUrl(current.url, 'download')
|
||||
.then(url => {
|
||||
if (!cancelled) {
|
||||
setSignedUrl(url)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [current?.url])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// The preview opens on top of the expense form, and both the desktop
|
||||
// Modal and the phone MSheet close themselves on Escape from a listener
|
||||
// on `document`. Without this the one keypress closed the form too and
|
||||
// threw away whatever the user had typed into it.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
|
||||
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
|
||||
}
|
||||
// Capture phase, so this runs before the listeners the parents put on
|
||||
// `document`; stopPropagation on the bubble phase would be too late.
|
||||
window.addEventListener('keydown', handleKey, true)
|
||||
return () => window.removeEventListener('keydown', handleKey, true)
|
||||
}, [index, receipts.length, onClose])
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const handleOpenTab = () => {
|
||||
openFileUrl(current.url, current.original_name).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
triggerDownload(current.url, current.original_name)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 950,
|
||||
height: '94vh',
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header - identical to PdfPreviewModal */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{current.original_name}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-faint)',
|
||||
display: 'flex',
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab strip if multiple receipts */}
|
||||
{receipts.length > 1 && (
|
||||
<div
|
||||
className="bg-surface-secondary"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.map((r, i) => {
|
||||
const active = i === index
|
||||
const isRImg = isImage(r.mime_type)
|
||||
const Icon = isRImg ? FileImage : FileText
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '5px 11px',
|
||||
borderRadius: 8,
|
||||
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||
background: active ? 'var(--bg-card)' : 'none',
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
|
||||
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{r.original_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
opacity: active ? 0.8 : 0.5,
|
||||
marginLeft: 2,
|
||||
}}
|
||||
>
|
||||
#{i + 1}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: isImg ? '#0f141c' : 'var(--bg-card)',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.length > 1 && index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i - 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{receipts.length > 1 && index < receipts.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i + 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPdf ? (
|
||||
<object
|
||||
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
|
||||
title={current.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
textDecoration: 'underline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.downloadPdf')}
|
||||
</button>
|
||||
</p>
|
||||
</object>
|
||||
) : isImg ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{signedUrl ? (
|
||||
<img
|
||||
src={signedUrl}
|
||||
alt={current.original_name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 6,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-content-faint" style={{ padding: 48 }}>
|
||||
{loading ? `${t('common.loading')}...` : current.original_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
|
||||
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
|
||||
{current.original_name}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.download')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
|
||||
// expense, and taking the uploads back out when the expense fails to save.
|
||||
import { vi } from 'vitest'
|
||||
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
|
||||
|
||||
const files = vi.hoisted(() => ({
|
||||
upload: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
permanentDelete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({ filesApi: files }))
|
||||
|
||||
function file(name: string) {
|
||||
return new File(['x'], name, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
|
||||
files.delete.mockReset().mockResolvedValue({})
|
||||
files.permanentDelete.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('uploadReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], 42)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBe('42')
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], null)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
|
||||
files.upload
|
||||
.mockResolvedValueOnce({ file: { id: 11 } })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ file: { id: 13 } })
|
||||
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
|
||||
})
|
||||
})
|
||||
|
||||
describe('discardReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
|
||||
const order: string[] = []
|
||||
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
|
||||
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
|
||||
|
||||
expect(await discardReceipts(3, [9])).toEqual([])
|
||||
expect(order).toEqual(['delete', 'permanent'])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
expect(await discardReceipts(3, [9, 10])).toEqual([9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWithReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockResolvedValue('saved')
|
||||
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
|
||||
expect(save).toHaveBeenCalledWith([5, 6])
|
||||
expect(out.result).toBe('saved')
|
||||
expect(files.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockRejectedValue(new Error('nope'))
|
||||
|
||||
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
const save = vi.fn()
|
||||
|
||||
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
|
||||
expect(err.stuckReceiptIds).toEqual([5])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { filesApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Uploading receipts alongside an expense, and undoing it when the expense
|
||||
* itself fails to save.
|
||||
*
|
||||
* One module for both shells. The desktop form and the phone sheet each had
|
||||
* their own copy of this, three near-identical try/catch blocks apiece, which is
|
||||
* six places for the same bug to sit and six copies against the duplication
|
||||
* budget the PR gate measures.
|
||||
*/
|
||||
|
||||
/** Upload each file and return the ids the server handed back, in order. */
|
||||
export async function uploadReceipts(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
// Only an existing expense can be named here. A new one has no id yet, so
|
||||
// its receipts are linked by the create call instead.
|
||||
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
|
||||
const res = await filesApi.upload(tripId, fd)
|
||||
if (res?.file?.id) ids.push(res.file.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo an upload after the save it belonged to failed.
|
||||
*
|
||||
* Two calls per file, and the order matters: the permanent route only accepts a
|
||||
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
|
||||
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
|
||||
* delete puts it there first. It also drops the receipt link the upload created,
|
||||
* so a failed edit does not leave the file attached to the expense.
|
||||
*
|
||||
* Returns the ids it could not remove. The caller is expected to say so rather
|
||||
* than swallow it: a file left behind is a file the user has to find in the
|
||||
* Files tab and delete by hand.
|
||||
*/
|
||||
export async function discardReceipts(
|
||||
tripId: number | string,
|
||||
ids: readonly number[],
|
||||
): Promise<number[]> {
|
||||
const stuck: number[] = []
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
await filesApi.permanentDelete(tripId, id)
|
||||
} catch {
|
||||
stuck.push(id)
|
||||
}
|
||||
}
|
||||
return stuck
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the pending receipts, run the save, and take the uploads back out
|
||||
* again if it throws.
|
||||
*
|
||||
* The rollback runs for a failure in the save AND for one partway through the
|
||||
* upload loop, which is why the ids are collected outside the try.
|
||||
*/
|
||||
export async function saveWithReceipts<T>(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
save: (uploadedIds: number[]) => Promise<T>,
|
||||
): Promise<{ result: T; stuckIds: number[] }> {
|
||||
const uploaded: number[] = []
|
||||
try {
|
||||
for (const file of files) {
|
||||
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
|
||||
if (id != null) uploaded.push(id)
|
||||
}
|
||||
const result = await save(uploaded)
|
||||
return { result, stuckIds: [] }
|
||||
} catch (err) {
|
||||
const stuck = await discardReceipts(tripId, uploaded)
|
||||
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
|
||||
import type { User } from '../../types'
|
||||
import { useCollabChat } from './useCollabChat'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
@@ -14,7 +14,7 @@ interface CollabChatProps {
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const S = useCollabChat(tripId, currentUser)
|
||||
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
<ChatMessages {...S} />
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
@@ -48,6 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
{canEdit && (
|
||||
@@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -76,15 +80,16 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
{canEdit && (
|
||||
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <>
|
||||
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
|
||||
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
|
||||
</>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { CollabChatAttachment } from './CollabChatAttachment'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -166,6 +167,7 @@ export function ChatMessages(props: any) {
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
interface CollabLink {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
function LinkIcon({ url, title }: { url: string; title: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
|
||||
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
|
||||
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
|
||||
}
|
||||
|
||||
export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const canEdit = useCanDo()('collab_edit', trip)
|
||||
const toast = useToast()
|
||||
const [links, setLinks] = useState<CollabLink[]>([])
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
collabApi.getLinks(tripId)
|
||||
.then(d => { if (!cancelled) setLinks(d.links || []) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
console.error('Failed to load collab links:', err)
|
||||
toast.error(t('common.error'))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, toast, t])
|
||||
|
||||
// Live sync, the same shape the notes panel uses. Without it the three
|
||||
// collab:link events the server broadcasts would arrive nowhere and a link
|
||||
// somebody else added would only appear on the next reload.
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
|
||||
// The panel is not remounted on a trip change, so an event still in
|
||||
// flight from the trip we just left must not land in this list.
|
||||
if (String(msg?.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:link:created' && msg.link) {
|
||||
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:link:updated' && msg.link) {
|
||||
setLinks(prev => prev
|
||||
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
}
|
||||
if (msg.type === 'collab:link:deleted' && msg.linkId) {
|
||||
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId])
|
||||
|
||||
// Every write says something when it fails. Silence left the pin looking
|
||||
// unchanged and the delete looking ignored, with an unhandled rejection in
|
||||
// the console as the only trace.
|
||||
const add = async (event?: FormEvent) => {
|
||||
event?.preventDefault()
|
||||
if (!title.trim() || !url.trim() || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
|
||||
setLinks(v => [d.link, ...v])
|
||||
setTitle(''); setUrl(''); setExpanded(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to add collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (link: CollabLink) => {
|
||||
try {
|
||||
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
|
||||
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
} catch (err) {
|
||||
console.error('Failed to pin collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await collabApi.deleteLink(tripId, id)
|
||||
setLinks(v => v.filter(x => x.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
|
||||
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
|
||||
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
|
||||
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
|
||||
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
|
||||
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
|
||||
</form>}
|
||||
</div>}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
|
||||
</div>
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
|
||||
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
|
||||
vi.mock('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</div> }))
|
||||
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
|
||||
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
@@ -39,11 +40,12 @@ describe('CollabPanel', () => {
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-001
|
||||
it('desktop layout renders all four panels', () => {
|
||||
it('desktop layout renders all five panels', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
@@ -55,6 +57,7 @@ describe('CollabPanel', () => {
|
||||
// Tab buttons exist
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^links$/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
|
||||
// Only chat visible by default
|
||||
@@ -73,6 +76,13 @@ describe('CollabPanel', () => {
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mobile: Links tab shows the translated label, not the i18n key', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-004
|
||||
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||
setViewport(375)
|
||||
@@ -147,7 +157,7 @@ describe('CollabPanel', () => {
|
||||
// FE-W5CPN-001 to FE-W5CPN-013
|
||||
// The desktop branch picks a different layout for every combination of enabled
|
||||
// collab features, so each combination gets its own case.
|
||||
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
|
||||
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
|
||||
|
||||
describe('CollabPanel feature combinations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -172,6 +182,7 @@ describe('CollabPanel feature combinations', () => {
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -253,6 +264,42 @@ describe('CollabPanel feature combinations', () => {
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The three-or-more branch used to render notes and links as a pair and drop
|
||||
// whichever of the two was on its own, so these walk every shape of it.
|
||||
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
|
||||
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
|
||||
setViewport(375)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import CollabLinks from './CollabLinks'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
@@ -28,6 +29,7 @@ interface TripMember {
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
links?: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
@@ -39,10 +41,11 @@ interface CollabPanelProps {
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
@@ -50,12 +53,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
const { t } = useTranslation()
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
// Older server/admin configs predate the Links feature; merge defaults so a
|
||||
// missing `links` key does not silently hide the new panel.
|
||||
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey),
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
@@ -70,11 +75,28 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.notes && 'notes', features.links && 'links',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
// One place decides what a panel id renders. It was spelled out at five call
|
||||
// sites, and the layout branch below dropped a panel precisely because one of
|
||||
// those copies did not list it.
|
||||
const renderPanel = (p: string) => (
|
||||
<>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'links' && <CollabLinks tripId={tripId} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</>
|
||||
)
|
||||
const panelRow = (ids: string[]) => (
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
|
||||
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
@@ -99,35 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
{rightPanels.length <= 2 && panelRow(rightPanels)}
|
||||
{rightPanels.length >= 3 && (() => {
|
||||
// Two rows, split by kind rather than by a pair being present:
|
||||
// the old condition needed notes AND links together, so turning
|
||||
// the new Links feature off dropped Notes out of the layout
|
||||
// entirely. Each row renders only when it has something in it.
|
||||
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
|
||||
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
|
||||
return <>
|
||||
{written.length > 0 && panelRow(written)}
|
||||
{rest.length > 0 && panelRow(rest)}
|
||||
</>
|
||||
)}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -135,26 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{panels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -170,7 +160,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{tabs.map(tab => {
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
@@ -187,6 +177,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The images pinned to a chat message before it is sent.
|
||||
*
|
||||
* One hook for both shells. The desktop hook and the phone screen each carried
|
||||
* their own copy of the same picking, previewing and clearing, which is two
|
||||
* places for the same leak and a duplication bill on every line.
|
||||
*
|
||||
* Object URLs are held in a ref and revoked when the file they belong to goes,
|
||||
* when the list is cleared and on unmount. Rebuilding every preview on each
|
||||
* change, which is what both copies did, leaked one URL per image per keystroke
|
||||
* of picking, and did it from inside a setState updater, so React ran it twice
|
||||
* in development and leaked twice as fast.
|
||||
*/
|
||||
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
export const MAX_CHAT_IMAGES = 4
|
||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export interface ChatImages {
|
||||
files: File[]
|
||||
previews: string[]
|
||||
/** Returns false when something was dropped, so the caller can say why. */
|
||||
add: (incoming: FileList | File[]) => boolean
|
||||
remove: (index: number) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function useChatImages(): ChatImages {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [previews, setPreviews] = useState<string[]>([])
|
||||
const urls = useRef<string[]>([])
|
||||
|
||||
const revokeAll = useCallback(() => {
|
||||
urls.current.forEach(URL.revokeObjectURL)
|
||||
urls.current = []
|
||||
}, [])
|
||||
|
||||
useEffect(() => revokeAll, [revokeAll])
|
||||
|
||||
const add = useCallback((incoming: FileList | File[]) => {
|
||||
const all = Array.from(incoming)
|
||||
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
|
||||
if (valid.length) {
|
||||
setFiles(prev => {
|
||||
const room = MAX_CHAT_IMAGES - prev.length
|
||||
const added = valid.slice(0, Math.max(0, room))
|
||||
// Only the new files get a URL; the ones already in the list keep theirs.
|
||||
const fresh = added.map(URL.createObjectURL)
|
||||
urls.current = [...urls.current, ...fresh]
|
||||
setPreviews(p => [...p, ...fresh])
|
||||
return [...prev, ...added]
|
||||
})
|
||||
}
|
||||
return valid.length === all.length
|
||||
}, [])
|
||||
|
||||
const remove = useCallback((index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index))
|
||||
setPreviews(prev => {
|
||||
const gone = prev[index]
|
||||
if (gone) {
|
||||
URL.revokeObjectURL(gone)
|
||||
urls.current = urls.current.filter(u => u !== gone)
|
||||
}
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
revokeAll()
|
||||
setFiles([])
|
||||
setPreviews([])
|
||||
}, [revokeAll])
|
||||
|
||||
return { files, previews, add, remove, clear }
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatImages } from './useChatImages'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
@@ -23,6 +24,8 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const images = useChatImages()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
@@ -38,6 +41,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
@@ -117,23 +121,35 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const addImageFiles = useCallback((incoming: File[] | FileList) => {
|
||||
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
|
||||
}, [images, toast, t])
|
||||
const removeImage = images.remove
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
|
||||
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
if ((!body && !images.files.length) || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
let data
|
||||
if (images.files.length) {
|
||||
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
|
||||
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
|
||||
} else {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
|
||||
data = await collabApi.sendMessage(tripId, payload)
|
||||
}
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
@@ -175,5 +191,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
interface LocationBias {
|
||||
low: { lat: number; lng: number }
|
||||
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
|
||||
/**
|
||||
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
|
||||
*
|
||||
* The panel is positioned against the FILTER ROW rather than against this
|
||||
* button, so it opens across the full width of the row instead of the half its
|
||||
* own trigger occupies. The two filters sit side by side and only one can be
|
||||
* open at a time, so the wider panel costs nothing and stops list names being
|
||||
* truncated at roughly ten characters. The row carries the `relative` this
|
||||
* needs; there is no other call site.
|
||||
*/
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
}, [open])
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1" ref={ref}>
|
||||
<div className="min-w-0 flex-1" ref={ref}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
||||
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
||||
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
{options.map(o => (
|
||||
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
@@ -173,7 +183,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
// Same 320px as the details column on the other side of the form: two panels
|
||||
// of different widths flanking one form read as a mistake rather than a
|
||||
// hierarchy, and neither of them is the more important one.
|
||||
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Bookmark size={15} className="text-accent" />
|
||||
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
||||
@@ -189,7 +202,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
/>
|
||||
</div>
|
||||
{lists.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex gap-2">
|
||||
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
</div>
|
||||
@@ -201,7 +214,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
|
||||
<EmptyState
|
||||
scene="search"
|
||||
title={t('collections.picker.empty')}
|
||||
size={84}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{page.map(place => (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
function open(value = '2026-09-11') {
|
||||
const onChange = vi.fn()
|
||||
render(<DatePicker value={value} onChange={onChange} />)
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
|
||||
const prev = (name: RegExp) => screen.getByRole('button', { name })
|
||||
|
||||
describe('DatePicker year navigation (#2318)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
|
||||
// Back on the months, of the chosen year.
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(prev(/previous month/i))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(prev(/previous year/i))
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(prev(/previous years/i))
|
||||
expect(header()).toHaveTextContent('2004 – 2015')
|
||||
await user.click(screen.getByRole('button', { name: /next years/i }))
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
await user.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith('2023-03-15')
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
const trigger = screen.getByText(/Sep 11, 2026/)
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
|
||||
* learnt one calendar in TREK knows the other. Without it the only way to a
|
||||
* different year was one month at a time, which on a photo picker asked to
|
||||
* cover a trip three years back meant thirty-six clicks (#2318).
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
|
||||
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
|
||||
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months')
|
||||
} else if (view === 'months') {
|
||||
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
|
||||
setView('years')
|
||||
}
|
||||
}
|
||||
const selectMonth = (month: number) => {
|
||||
setViewMonth(p => ({ ...p, month }))
|
||||
setView('days')
|
||||
}
|
||||
const selectYear = (year: number) => {
|
||||
setViewMonth(p => ({ ...p, year }))
|
||||
setView('months')
|
||||
}
|
||||
const toggleOpen = () => {
|
||||
// Reopening lands on the days again: a picker left on the year grid
|
||||
// would otherwise open there next time, one level away from a date.
|
||||
setView('days')
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
|
||||
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
|
||||
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
|
||||
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
|
||||
const headerLabel = view === 'days' ? monthName
|
||||
: view === 'months' ? String(viewMonth.year)
|
||||
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
|
||||
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
|
||||
: view === 'months' ? t('common.datepicker.selectYear') : undefined
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const monthNames = Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
|
||||
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
|
||||
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
aria-label={headerAria}
|
||||
disabled={view === 'years'}
|
||||
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
{view === 'months' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{monthNames.map((name, i) => (
|
||||
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'years' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map(y => (
|
||||
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'days' && (
|
||||
<>
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-020
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -324,6 +324,39 @@ describe('ProviderPicker', () => {
|
||||
expect(range.to.endsWith('-09')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-021: a location picked after the photos loaded reorders the grid', async () => {
|
||||
// contextLocation is live editor state: the place search and "use my location"
|
||||
// write it while this picker stays mounted, and the key is only provider plus
|
||||
// date. Sorting inside the fetch instead of in a memo leaves the grid frozen
|
||||
// while the caption above it already claims nearest-first.
|
||||
searchReturns([
|
||||
asset('rome', { lat: 41.9, lng: 12.5, takenAt: '2026-03-15T09:00:00.000Z' }),
|
||||
asset('helsinki', { lat: 60.17, lng: 24.94, takenAt: '2026-03-15T11:00:00.000Z' }),
|
||||
])
|
||||
const props = {
|
||||
provider: 'immich',
|
||||
userId: 42,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds: new Set<string>(),
|
||||
onClose: vi.fn(),
|
||||
onAdd: vi.fn(async () => {}),
|
||||
} as React.ComponentProps<typeof ProviderPicker>
|
||||
const { container, rerender } = render(<ProviderPicker {...props} />)
|
||||
await screen.findByText('March 15, 2026')
|
||||
|
||||
const order = () => Array.from(container.querySelectorAll('img'))
|
||||
.map(img => (img.getAttribute('src') || '').split('/assets/0/')[1]?.split('/')[0])
|
||||
.filter(Boolean)
|
||||
|
||||
// Nothing to be near yet, so newest first.
|
||||
expect(order()).toEqual(['helsinki', 'rome'])
|
||||
|
||||
rerender(<ProviderPicker {...props} contextLocation={{ lat: 41.9, lng: 12.5, name: 'Rome' }} />)
|
||||
|
||||
await waitFor(() => expect(order()).toEqual(['rome', 'helsinki']))
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-020: closes through the header button and the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, onClose } = mountPicker()
|
||||
|
||||
@@ -1,189 +1,244 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { memoriesApi } from '../../api/client'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import { Calendar, Camera, Check, ChevronRight, Play, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
groupPhotosByDate,
|
||||
sortProviderPhotos,
|
||||
type GeoPoint,
|
||||
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker';
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger';
|
||||
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] };
|
||||
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
|
||||
provider: string
|
||||
userId: number
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
|
||||
initialDate?: string
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null
|
||||
initialEntryId?: number | null
|
||||
embedded?: boolean
|
||||
export function ProviderPicker({
|
||||
provider,
|
||||
userId,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds,
|
||||
onClose,
|
||||
onAdd,
|
||||
initialDate,
|
||||
contextLocation,
|
||||
initialEntryId,
|
||||
embedded = false,
|
||||
}: {
|
||||
provider: string;
|
||||
userId: number;
|
||||
entries: JourneyEntry[];
|
||||
trips: JourneyTrip[];
|
||||
existingAssetIds: Set<string>;
|
||||
onClose: () => void;
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>;
|
||||
initialDate?: string;
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null;
|
||||
initialEntryId?: number | null;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip');
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [albums, setAlbums] = useState<
|
||||
Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
||||
>([]);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [searchFrom, setSearchFrom] = useState('');
|
||||
const [searchTo, setSearchTo] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(
|
||||
new Map()
|
||||
);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null);
|
||||
const [addToOpen, setAddToOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// compute trip range
|
||||
const tripRange = useMemo(() => {
|
||||
let from = '', to = ''
|
||||
let from = '',
|
||||
to = '';
|
||||
for (const t of trips) {
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date;
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date;
|
||||
}
|
||||
return { from, to }
|
||||
}, [trips])
|
||||
return { from, to };
|
||||
}, [trips]);
|
||||
|
||||
const cancelPending = () => {
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
abortRef.current = new AbortController()
|
||||
return abortRef.current.signal
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
return abortRef.current.signal;
|
||||
};
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending()
|
||||
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||
setSearchFrom(from)
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
const signal = cancelPending();
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setSearchFrom(from);
|
||||
setSearchTo(to);
|
||||
setSearchPage(page);
|
||||
try {
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
|
||||
const assets = data.assets || [];
|
||||
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
|
||||
setHasMore(!!data.hasMore);
|
||||
} catch {
|
||||
// A cancelled request is about to be replaced by the next one, so leave
|
||||
// its paging state alone.
|
||||
if (!signal.aborted) setHasMore(false)
|
||||
if (!signal.aborted) setHasMore(false);
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
if (loadingMore || !hasMore) return;
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
|
||||
};
|
||||
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
const signal = cancelPending();
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
setHasMore(false);
|
||||
try {
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||
} catch { /* ignore */ }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
const assets = (await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [];
|
||||
setPhotos(assets);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!signal.aborted) setLoading(false);
|
||||
};
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || []);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// load on mount / filter change
|
||||
useEffect(() => {
|
||||
if (filter === 'day' && initialDate) {
|
||||
searchPhotos(initialDate, initialDate)
|
||||
searchPhotos(initialDate, initialDate);
|
||||
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
searchPhotos(tripRange.from, tripRange.to);
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '')
|
||||
searchPhotos('', '');
|
||||
} else if (filter === 'album' && albums.length === 0) {
|
||||
loadAlbums()
|
||||
loadAlbums();
|
||||
}
|
||||
}, [filter])
|
||||
}, [filter]);
|
||||
|
||||
const handleCustomSearch = () => {
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
||||
}
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo);
|
||||
};
|
||||
|
||||
// Sorted here rather than in the two fetches above. contextLocation is live
|
||||
// editor state: the place search and "use my location" write it while this
|
||||
// picker stays mounted, and its key is only provider plus date, so a fetch-time
|
||||
// sort would leave the grid frozen while the caption already says "near Rome".
|
||||
// It also keeps the ranking global instead of per page, which a sort inside
|
||||
// the appending search cannot do.
|
||||
const sortedPhotos = useMemo(
|
||||
() => sortProviderPhotos(photos, contextLocation),
|
||||
[photos, contextLocation?.lat, contextLocation?.lng],
|
||||
)
|
||||
);
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
next.delete(id);
|
||||
} else {
|
||||
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
|
||||
const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType;
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType });
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery')
|
||||
? entries.find((e) => e.id === targetEntryId)?.title ||
|
||||
entries.find((e) => e.id === targetEntryId)?.entry_date ||
|
||||
t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
|
||||
className={embedded
|
||||
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
|
||||
}
|
||||
role="presentation"
|
||||
onClick={embedded ? undefined : onClose}
|
||||
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
|
||||
onTouchMove={(e) => {
|
||||
if (!embedded && e.target === e.currentTarget) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={embedded
|
||||
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
|
||||
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
|
||||
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
|
||||
}
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>}
|
||||
{!embedded && (
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
{[
|
||||
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button type="button"
|
||||
].map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
|
||||
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
filter === f.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
@@ -192,180 +247,252 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : f.label}
|
||||
) : (
|
||||
f.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter content — always visible row */}
|
||||
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<div className="flex min-h-[36px] items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
(
|
||||
{Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) +
|
||||
1}{' '}
|
||||
days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'custom' && (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
|
||||
<span className="text-zinc-400 text-[12px]">—</span>
|
||||
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
||||
<button type="button" onClick={handleCustomSearch}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex gap-2 overflow-x-auto flex-1">
|
||||
{albums.map((a: any) => (
|
||||
<button type="button"
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
{filter === 'custom' && (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customFrom} onChange={setCustomFrom} />
|
||||
</div>
|
||||
<span className="text-[12px] text-zinc-400">—</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customTo} onChange={setCustomTo} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSearch}
|
||||
className="flex-shrink-0 rounded-lg bg-zinc-900 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex flex-1 gap-2 overflow-x-auto">
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setSelectedAlbum(a.id);
|
||||
setSelectedAlbumPassphrase(a.passphrase);
|
||||
loadAlbumPhotos(a);
|
||||
}}
|
||||
className={`flex-shrink-0 rounded-lg border px-2.5 py-1 text-[11px] font-medium whitespace-nowrap ${
|
||||
selectedAlbum === a.id
|
||||
? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}
|
||||
{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && (
|
||||
<span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
||||
<button type="button"
|
||||
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
||||
<button type="button"
|
||||
key={e.id}
|
||||
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
{!embedded && (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-zinc-50 px-6 py-2.5 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] text-zinc-500 uppercase">
|
||||
{t('journey.picker.addTo')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-200 px-3 py-1.5 text-[12px] font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute top-full left-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTargetEntryId(null);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{entries.filter(
|
||||
(e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]'
|
||||
).length > 0 && <div className="my-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
|
||||
{entries
|
||||
.filter((e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||
.map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e.id}
|
||||
onClick={() => {
|
||||
setTargetEntryId(e.id);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`w-full truncate px-3 py-2 text-left text-[12px] ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title ||
|
||||
e.location_name ||
|
||||
new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
)}
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && sortedPhotos.length > 0 && (() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
allSelected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!loading &&
|
||||
sortedPhotos.length > 0 &&
|
||||
(() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
|
||||
if (selectable.length === 0) return null;
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-white px-4 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(
|
||||
new Map(
|
||||
selectable.map((a: any) => [
|
||||
a.id,
|
||||
{
|
||||
albumId: selectedAlbum ?? undefined,
|
||||
passphrase: selectedAlbumPassphrase,
|
||||
mediaType: a.mediaType,
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div
|
||||
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
|
||||
allSelected
|
||||
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
|
||||
</div>
|
||||
) : sortedPhotos.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
||||
{filter === 'trip' && !tripRange.from
|
||||
? t('journey.trips.noTripsLinkedSettings')
|
||||
: t('journey.detail.noPhotos')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(sortedPhotos).map(group => (
|
||||
{groupPhotosByDate(sortedPhotos).map((group) => (
|
||||
<div key={group.date}>
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
const isSelected = selected.has(asset.id);
|
||||
const alreadyAdded = existingAssetIds.has(asset.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -374,42 +501,47 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
aria-pressed={isSelected}
|
||||
aria-label={asset.city || group.label}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
|
||||
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
|
||||
if (!img.src.includes('/original')) img.src = original;
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.mediaType === 'video' && (
|
||||
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
|
||||
<Play size={8} className="ml-px text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
|
||||
<p className="truncate text-[8px] text-white">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,35 +553,58 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-200/60 px-2.5 py-1 text-[11px] leading-none text-zinc-500 dark:bg-zinc-700/60 dark:text-zinc-400">
|
||||
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] leading-none font-bold text-white dark:bg-white dark:text-zinc-900">
|
||||
{selected.size}
|
||||
</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-200 px-3.5 py-2 text-[13px] font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
|
||||
g.assetIds.push(assetId)
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
|
||||
groupMap.set(passphrase, g)
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>();
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] };
|
||||
g.assetIds.push(assetId);
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image');
|
||||
groupMap.set(passphrase, g);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({
|
||||
assetIds: g.assetIds,
|
||||
mediaTypes: g.mediaTypes,
|
||||
passphrase,
|
||||
}));
|
||||
await onAdd(groups, targetEntryId);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={selected.size === 0 || adding}
|
||||
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
{adding ? (
|
||||
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-zinc-400 border-t-white dark:border-t-zinc-900" />
|
||||
) : (
|
||||
<>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-006
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-009
|
||||
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
@@ -87,4 +87,44 @@ describe('ScrollTrigger', () => {
|
||||
unmount()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-007: a fresh callback identity alone does not rebuild the observer', () => {
|
||||
// Both callers hand in a new arrow every render; rebuilding for that churn
|
||||
// replays the initial callback and pulls pages nobody asked for.
|
||||
const { rerender } = render(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-008: the surviving observer calls the newest callback, not the one it was built with', () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={first} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={second} loading={false} />)
|
||||
|
||||
intersect(observers[0], true)
|
||||
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-009: a settled page really re-observes the sentinel', () => {
|
||||
// A browser observer only fires on a threshold crossing plus once per
|
||||
// observe(). A sentinel that never leaves the 200px margin therefore only
|
||||
// gets another callback because the loading flip rebuilds the observer, so
|
||||
// loading must stay a dependency rather than moving into a ref as well.
|
||||
const onVisible = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={onVisible} loading />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={onVisible} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(2)
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,24 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
export function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
// Both callers hand in a fresh arrow on every render — the picker its
|
||||
// loadMorePhotos, the gallery its setVisibleCount step. With onVisible in the
|
||||
// effect deps, every unrelated re-render tore the observer down and observed
|
||||
// again, and each observe() replays the initial callback. The ref keeps the
|
||||
// observer out of that churn.
|
||||
//
|
||||
// loading stays a dependency on purpose: re-observing after a page has landed
|
||||
// is the only thing that still produces a callback when the sentinel never
|
||||
// left the 200px margin.
|
||||
const onVisibleRef = useRef(onVisible)
|
||||
onVisibleRef.current = onVisible
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisibleRef.current() }, { rootMargin: '200px' })
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [onVisible, loading])
|
||||
}, [loading])
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-4 mt-2">
|
||||
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
||||
|
||||
@@ -86,6 +86,9 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
|
||||
// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These
|
||||
// cases are about the GL layer, so the probe says yes here.
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} }));
|
||||
|
||||
const entriesWithCoords = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap'
|
||||
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
|
||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
@@ -162,7 +162,8 @@ function JourneyMap(
|
||||
const tileUrlRef = useRef(tileUrl)
|
||||
tileUrlRef.current = tileUrl
|
||||
const tileLayerRef = useRef<L.TileLayer | null>(null)
|
||||
const glLayerRef = useRef<GlLeafletLayer | null>(null)
|
||||
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
|
||||
const glLayerRef = useRef<BasemapLayer | null>(null)
|
||||
// The vector basemap loads async; a map torn down before it lands must not get one.
|
||||
const cancelledRef = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -379,7 +380,7 @@ function JourneyMap(
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
tileLayerRef.current = null
|
||||
glLayerRef.current?.remove()
|
||||
detachBasemapLayer(glLayerRef.current)
|
||||
glLayerRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
@@ -389,7 +390,7 @@ function JourneyMap(
|
||||
// marker and track it just drew. A vector basemap restyles instead, which also
|
||||
// avoids spending a WebGL context on every theme toggle.
|
||||
useEffect(() => {
|
||||
if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl)
|
||||
if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl)
|
||||
else tileLayerRef.current?.setUrl(tileUrl)
|
||||
}, [tileUrl])
|
||||
|
||||
|
||||
@@ -25,14 +25,33 @@ describe('buildNavItems', () => {
|
||||
})
|
||||
|
||||
describe('splitMobileNav', () => {
|
||||
it('falls back to Dashboard + first two in the bar when there is no config', () => {
|
||||
it('docks the top two enabled defaults when there is no config', () => {
|
||||
for (const cfg of [undefined, { bar: [], more: [] }]) {
|
||||
const s = splitMobileNav(items, cfg)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual(['journey', 'collections'])
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'journey'])
|
||||
// Atlas is still reachable, one tap further under More.
|
||||
expect(ids(s.more)).toEqual(['atlas', 'collections'])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the Vacay/Atlas dock on an instance where Journey is switched off', () => {
|
||||
const s = splitMobileNav([item('dashboard'), item('vacay'), item('atlas')], undefined)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual([])
|
||||
})
|
||||
|
||||
it('fills the free slot from the next default rather than leaving it empty', () => {
|
||||
const s = splitMobileNav([item('dashboard'), item('atlas'), item('journey')], undefined)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'journey', 'atlas'])
|
||||
expect(ids(s.more)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves an account that picked its own dock alone', () => {
|
||||
const s = splitMobileNav(items, { bar: ['vacay', 'atlas'], more: ['journey', 'collections'] })
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual(['journey', 'collections'])
|
||||
})
|
||||
|
||||
it('honours a custom split and order, Dashboard still first', () => {
|
||||
const s = splitMobileNav(items, { bar: ['journey'], more: ['collections', 'vacay', 'atlas'] })
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'journey'])
|
||||
|
||||
@@ -35,8 +35,18 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
|
||||
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
|
||||
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
|
||||
export const MOBILE_NAV_MAX_BAR = 2
|
||||
/** The built-in dock next to Dashboard for an un-customised account; everything else starts under More. */
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'atlas']
|
||||
/**
|
||||
* The built-in dock next to Dashboard for an un-customised account, in priority
|
||||
* order: the first MOBILE_NAV_MAX_BAR of these that are actually enabled take the
|
||||
* slots, everything else starts under More.
|
||||
*
|
||||
* Journey outranks Atlas because a phone in the field wants the journal it writes
|
||||
* to every day, not the map of countries it already visited. Journey ships
|
||||
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
|
||||
* dock it has today. Listing all four known global addons rather than just the
|
||||
* top two also keeps the dock filled when one of them is switched off.
|
||||
*/
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
|
||||
|
||||
export function buildNavItems(
|
||||
globalAddons: { id: string; name: string; icon: string }[],
|
||||
@@ -90,10 +100,10 @@ export interface MobileNavSplit {
|
||||
/**
|
||||
* Resolve the persisted `{ bar, more }` id split against the live nav items.
|
||||
* Dashboard is always pinned first in the bar. An empty/absent config falls back
|
||||
* to the built-in dock (Dashboard + Vacay/Atlas where enabled, everything else
|
||||
* under More) so an un-customised user sees exactly today's layout. Stored ids
|
||||
* that no longer resolve are dropped; newly available items are appended under
|
||||
* More.
|
||||
* to the built-in dock (Dashboard + the top DEFAULT_DOCK_IDS that are enabled,
|
||||
* everything else under More). Stored ids that no longer resolve are dropped;
|
||||
* newly available items are appended under More, so an account that once picked
|
||||
* its own dock keeps it untouched.
|
||||
*/
|
||||
export function splitMobileNav(
|
||||
items: NavItemDef[],
|
||||
@@ -102,16 +112,20 @@ export function splitMobileNav(
|
||||
const dashboard = items.find((i) => i.id === 'dashboard')
|
||||
const head = dashboard ? [dashboard] : []
|
||||
const rest = items.filter((i) => i.id !== 'dashboard')
|
||||
const byId = new Map(rest.map((i) => [i.id, i]))
|
||||
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
|
||||
|
||||
if (!cfg || (cfg.bar.length === 0 && cfg.more.length === 0)) {
|
||||
const dock = pick(DEFAULT_DOCK_IDS).slice(0, MOBILE_NAV_MAX_BAR)
|
||||
const docked = new Set(dock.map((i) => i.id))
|
||||
return {
|
||||
bar: [...head, ...rest.filter((i) => DEFAULT_DOCK_IDS.includes(i.id))],
|
||||
more: rest.filter((i) => !DEFAULT_DOCK_IDS.includes(i.id)),
|
||||
bar: [...head, ...dock],
|
||||
// Store order, not DEFAULT_DOCK_IDS order, so More keeps reading like the
|
||||
// addon list itself.
|
||||
more: rest.filter((i) => !docked.has(i.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map(rest.map((i) => [i.id, i]))
|
||||
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
|
||||
const known = new Set([...cfg.bar, ...cfg.more])
|
||||
|
||||
const barPicked = pick(cfg.bar)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -77,7 +78,7 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Map as MapIcon, Satellite } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
|
||||
@@ -18,7 +19,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
|
||||
// A flat projection, 1000 px per degree: far enough apart that every
|
||||
// booking line clears its declutter floor, which is measured along the
|
||||
// projected line (#2275) rather than read off a canned distanceTo.
|
||||
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
|
||||
x: lng * 1000, y: lat * 1000,
|
||||
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
|
||||
})),
|
||||
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
|
||||
panes: new Map<string, HTMLElement>(),
|
||||
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
|
||||
@@ -221,6 +227,24 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
expect(casing.color).toBe('#c2740a')
|
||||
expect(core.color).toBe('#ff9f0a')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
expect(casing.color).toBe('#0a5cc2')
|
||||
expect(core.color).toBe('#0a84ff')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||
render(<MapView route={null} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
|
||||
@@ -606,6 +606,9 @@ export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
// One colour pair per entry of `route`, or absent for the blue the route has always
|
||||
// been. Only the road trip passes these, and only while colouring by day is on.
|
||||
routeColors = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
@@ -937,7 +940,12 @@ export const MapView = memo(function MapView({
|
||||
// Store flips state synchronously (instant, works offline); a failed save is logged there.
|
||||
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
|
||||
}, [isSatellite, updateSetting])
|
||||
const switcherBottom = hasDayDetail
|
||||
// The day panel is a centred card between the two sidebars, so on the desktop
|
||||
// map it never reaches this pill sitting hard against the left sidebar. Lifting
|
||||
// it by the panel height only made it hop on every collapse and expand, since
|
||||
// --day-panel-h changes while hasDayDetail stays true. On a phone the panel is
|
||||
// full width and does cover the pill, so the lift stays there.
|
||||
const switcherBottom = isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)'
|
||||
|
||||
@@ -1027,7 +1035,7 @@ export const MapView = memo(function MapView({
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
@@ -1041,7 +1049,7 @@ export const MapView = memo(function MapView({
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
@@ -1540,6 +1540,21 @@ describe('MapViewGL', () => {
|
||||
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
|
||||
const routeSource = geoSource()
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
|
||||
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
|
||||
await act(async () => {})
|
||||
|
||||
const { features } = lastData(routeSource)
|
||||
expect(features[0].properties.color).toBe('#ff9f0a')
|
||||
expect(features[0].properties.casing).toBe('#c2740a')
|
||||
// No colour means the day route's own blue, which the layer paint falls back to.
|
||||
expect(features[1].properties.color).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
|
||||
const gpxSource = geoSource()
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
|
||||
@@ -1955,4 +1970,68 @@ describe('MapViewGL', () => {
|
||||
// in the wrong place.
|
||||
expect(layer.firstElementChild).not.toBe(handle)
|
||||
})
|
||||
// ── Satellite ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
|
||||
// A GL map cannot do that, because its basemap is a style with dozens of layers,
|
||||
// so the imagery goes on as a raster layer under everything TREK draws.
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
|
||||
} as never)
|
||||
glMap.getStyle.mockReturnValue({
|
||||
layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }],
|
||||
})
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({
|
||||
type: 'raster',
|
||||
tiles: [expect.stringContaining('arcgisonline.com')],
|
||||
}))
|
||||
// Anchored before the route, or the imagery would be painted over it.
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
|
||||
'trip-route',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
|
||||
} as never)
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
|
||||
} as never)
|
||||
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
|
||||
// Present from the first pass on, the way the real map reports it afterwards.
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
|
||||
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
|
||||
|
||||
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
act(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
|
||||
} as never)
|
||||
})
|
||||
rerender(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
|
||||
@@ -27,7 +28,7 @@ import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
@@ -48,6 +49,21 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
|
||||
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite'
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
const OWN_LAYER_PREFIXES = ['trip-', 'trek-']
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -107,6 +123,11 @@ interface Props {
|
||||
/** The dashed last bit to a place the road network does not reach. */
|
||||
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
|
||||
route?: [number, number][][] | null
|
||||
/**
|
||||
* One colour pair per entry of `route`, or absent for the blue the route has always
|
||||
* been. Only the road trip passes these, and only while colouring by day is on.
|
||||
*/
|
||||
routeColors?: ({ line: string; casing: string } | undefined)[] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
onMarkerClick?: (id: number) => void
|
||||
@@ -233,6 +254,44 @@ function addPlaceClusterLayers(map: any): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Guarded on its own absence, so the usual pass is
|
||||
* a no-op and only the visibility flips.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function applySatellite(map: any, on: boolean): void {
|
||||
try {
|
||||
if (!map.isStyleLoaded?.()) return
|
||||
if (!map.getSource(SATELLITE_SOURCE_ID)) {
|
||||
if (!on) return // Nothing to build while it is switched off.
|
||||
map.addSource(SATELLITE_SOURCE_ID, {
|
||||
type: 'raster',
|
||||
tiles: [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: SATELLITE_TILE_MAXZOOM,
|
||||
attribution: SATELLITE_TILE_ATTRIBUTION,
|
||||
})
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? []
|
||||
const firstOwn = layers.find((l: { id: string }) =>
|
||||
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
|
||||
map.addLayer({
|
||||
id: SATELLITE_LAYER_ID,
|
||||
type: 'raster',
|
||||
source: SATELLITE_SOURCE_ID,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
}, firstOwn?.id)
|
||||
}
|
||||
if (map.getLayer(SATELLITE_LAYER_ID)) {
|
||||
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
|
||||
}
|
||||
} catch { /* a style that refuses the layer keeps the plain basemap */ }
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
@@ -552,6 +611,7 @@ export function MapViewGL({
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
accessLines = NO_ACCESS_LINES,
|
||||
route = null,
|
||||
routeColors = null,
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
@@ -594,6 +654,15 @@ export function MapViewGL({
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
// The same stored choice the Leaflet map reads, so the two renderers agree.
|
||||
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
|
||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
||||
const isSatellite = baseLayer === 'satellite'
|
||||
const toggleBaseLayer = useCallback(() => {
|
||||
// The store flips synchronously, so the map switches even offline; a failed save
|
||||
// is logged there rather than blocking the switch.
|
||||
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
|
||||
}, [isSatellite, updateSetting])
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
|
||||
@@ -699,6 +768,23 @@ export function MapViewGL({
|
||||
viaCleanupRef.current = []
|
||||
viaPinsRef.current.forEach(p => p.remove())
|
||||
viaPinsRef.current = []
|
||||
/**
|
||||
* Zoomed out, the handles go away.
|
||||
*
|
||||
* A via is a handle for a few hundred metres of road, and at a continental zoom a
|
||||
* whole day's worth of them collapses into a cluster of dots over one town — not
|
||||
* something anybody can aim at, and dragging one there moves the route by kilometres
|
||||
* per pixel. Below this the drive is read, not shaped.
|
||||
*/
|
||||
const VIA_MIN_ZOOM = 9
|
||||
const applyViaZoom = () => {
|
||||
const on = map.getZoom() >= VIA_MIN_ZOOM
|
||||
for (const pin of viaPinsRef.current) {
|
||||
const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.()
|
||||
if (node) node.style.display = on ? '' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
for (const via of vias) {
|
||||
const el = document.createElement('span')
|
||||
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
|
||||
@@ -787,8 +873,26 @@ export function MapViewGL({
|
||||
el.removeEventListener('dblclick', swallow)
|
||||
})
|
||||
}
|
||||
|
||||
// Applied now and on every zoom that settles, so a handle that should not be there is
|
||||
// gone before the first frame rather than after the first gesture.
|
||||
applyViaZoom()
|
||||
map.on('zoomend', applyViaZoom)
|
||||
viaCleanupRef.current.push(() => map.off('zoomend', applyViaZoom))
|
||||
}, [vias, viasDraggable, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The other people's pointers, and this person's own going the other way.
|
||||
*
|
||||
* Built as plain elements on the same pin layer the vias use, so they ride the map
|
||||
* during a pan instead of being re-placed a frame later — the same reason the via
|
||||
* handles are not library markers.
|
||||
*
|
||||
* No interpolation between frames, unlike the studio's book: a map pans and zooms under
|
||||
* the arrow, so a position eased towards over several frames is a position that was
|
||||
* never true at any of them. Ten frames a second placed exactly reads as a hand; the
|
||||
* same frames chasing a moving target read as a drift.
|
||||
*/
|
||||
/**
|
||||
* The offered routes, drawn under the current one.
|
||||
*
|
||||
@@ -1115,7 +1219,9 @@ export function MapViewGL({
|
||||
id: 'trip-route-casing',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||
// Per feature where the caller gave one, else the blue the route has always
|
||||
// been. `coalesce` rather than a second layer: one source, one stroke.
|
||||
paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// An invisible band over the route, purely to be clicked. The drawn line is 8px
|
||||
@@ -1132,7 +1238,7 @@ export function MapViewGL({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
||||
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
@@ -1822,13 +1928,18 @@ export function MapViewGL({
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}))
|
||||
const features = (route || [])
|
||||
.map((seg, i) => ({ seg, colors: routeColors?.[i] }))
|
||||
.filter(({ seg }) => seg && seg.length > 1)
|
||||
.map(({ seg, colors }) => ({
|
||||
type: 'Feature' as const,
|
||||
// Null rather than absent: `coalesce` in the paint expression falls through on
|
||||
// null, and an absent property would make every line the default colour.
|
||||
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null },
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}))
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route, mapReady])
|
||||
}, [route, routeColors, mapReady])
|
||||
|
||||
// Update access-spur geojson
|
||||
useEffect(() => {
|
||||
@@ -2058,6 +2169,18 @@ export function MapViewGL({
|
||||
else map.once('load', apply)
|
||||
}, [userPosition, trackingMode, glProvider])
|
||||
|
||||
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
|
||||
// and reloading into the other keeps the choice. `styledata` is subscribed because a
|
||||
// basemap change rebuilds the style from scratch and takes the imagery with it.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const apply = () => applySatellite(map, isSatellite)
|
||||
apply()
|
||||
map.on('styledata', apply)
|
||||
return () => { map.off('styledata', apply) }
|
||||
}, [isSatellite, mapReady, glProvider])
|
||||
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||
@@ -2093,6 +2216,18 @@ export function MapViewGL({
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
)}
|
||||
{/* Same pill, same corner, same offsets as the Leaflet map: the switch should not
|
||||
move when an instance changes renderer. The day panel is a centred card on the
|
||||
desktop map and never reaches the pill, but on a phone it is full width and
|
||||
does, which is why the lift is only there. */}
|
||||
<div style={{
|
||||
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
|
||||
bottom: isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
||||
}}>
|
||||
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
the Leaflet map's overlay (no anchored popup, no photo). */}
|
||||
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => {
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// Endpoints 50 px apart, under the 80 px car floor, so the straight hop would
|
||||
// hide. The road between them loops out 0.3° (300 px) and back, which is what
|
||||
// is actually drawn — and what the person was missing on their map.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
unmount()
|
||||
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(1)
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => {
|
||||
// From and to sit 20 px apart; the stop is 400 px away, so the two legs
|
||||
// together draw 800 px of line.
|
||||
renderOverlay({
|
||||
reservations: [booking({
|
||||
id: 8, type: 'car',
|
||||
endpoints: [
|
||||
endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }),
|
||||
endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }),
|
||||
endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }),
|
||||
],
|
||||
})],
|
||||
})
|
||||
expect(lines()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
// Every point of the road projects within 30 px: nothing worth drawing yet.
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => {
|
||||
renderOverlay({ reservations: [booking({ endpoints: undefined })] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const ENDPOINT_PANE = 'reservation-endpoints'
|
||||
@@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
@@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
}, [reservations])
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
|
||||
return items.filter(item => {
|
||||
// A transit journey draws its real rail/bus alignment, not a straight from->to
|
||||
// line, so the endpoint-proximity declutter (which exists to hide tiny no-op
|
||||
// straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one
|
||||
// with no other places to tighten the map onto — hides the whole route (#1570).
|
||||
if (item.transitSegs.length > 0) return true
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return fromPx.distanceTo(toPx) >= minPx
|
||||
// Measured along what is drawn, not between the ends: a routed drive can
|
||||
// cover half the screen while its endpoints sit close together (#2275).
|
||||
return hopIsVisible(item.type, linesFor(item, roadRoutes), project)
|
||||
})
|
||||
}, [items, zoom, map])
|
||||
}, [items, zoom, map, roadRoutes])
|
||||
|
||||
const labelVisibleIds = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const item of visibleItems) {
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
||||
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
|
||||
}
|
||||
return set
|
||||
}, [visibleItems, zoom, map])
|
||||
@@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map((seg, segIdx) => (
|
||||
return linesFor(item, roadRoutes).map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { Marker, Tooltip } from 'react-leaflet'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
@@ -31,6 +31,9 @@ interface RoadtripViaMarkersProps {
|
||||
}
|
||||
|
||||
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
|
||||
/** Below this the handles are not drawn. Shared with the GL renderer's own copy. */
|
||||
const VIA_MIN_ZOOM = 9
|
||||
|
||||
export default function RoadtripViaMarkers({
|
||||
viasByDay, onMoveVia, onRemoveVia,
|
||||
}: RoadtripViaMarkersProps): React.ReactElement | null {
|
||||
@@ -44,6 +47,22 @@ export default function RoadtripViaMarkers({
|
||||
const handlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
handlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const draggable = !!onMoveVia
|
||||
/**
|
||||
* Zoomed out, the handles go away.
|
||||
*
|
||||
* A via is a handle for a few hundred metres of road, and at a continental zoom a whole
|
||||
* day's worth collapses into a cluster of dots over one town — not something anybody can
|
||||
* aim at, and a drag there moves the route by kilometres per pixel. Below this the drive
|
||||
* is read, not shaped.
|
||||
*/
|
||||
const map = useMap()
|
||||
const [zoomedIn, setZoomedIn] = useState(() => map.getZoom() >= VIA_MIN_ZOOM)
|
||||
useEffect(() => {
|
||||
const update = () => setZoomedIn(map.getZoom() >= VIA_MIN_ZOOM)
|
||||
update()
|
||||
map.on('zoomend', update)
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
|
||||
const markers = useMemo(() => vias.map(via => ({
|
||||
via,
|
||||
@@ -62,7 +81,7 @@ export default function RoadtripViaMarkers({
|
||||
},
|
||||
})), [vias])
|
||||
|
||||
if (!markers.length) return null
|
||||
if (!markers.length || !zoomedIn) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -797,6 +797,119 @@ describe('calculateAlternatives', () => {
|
||||
expect(asked).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The second engine, which is what made this whole branch produce anything on a
|
||||
// default install. Every case here overrides the 503 the shared handlers answer with,
|
||||
// so the OSRM path above stays the one the cases before this measure.
|
||||
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
const valhallaAnswer = (summary: Record<string, unknown>) => ({
|
||||
trip: {
|
||||
// The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }],
|
||||
summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary },
|
||||
},
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
expect(routes[1].distance).toBeCloseTo(130000, 0)
|
||||
expect(routes[1].duration).toBe(4500)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => {
|
||||
// use_highways: 0 is a weighting, so a leg with no way round the motorway comes back
|
||||
// on the motorway and says so. Offering that as "No motorway" would put a lie on the
|
||||
// map, so it is dropped and the leg keeps the single way OSRM found.
|
||||
//
|
||||
// Asserted on the result rather than on which excludes OSRM was asked: by this point
|
||||
// `excludeUnsupported` has already recorded the public host from the case above, and
|
||||
// that Set is module state for the lifetime of the file.
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].avoids).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing_options: { auto: Record<string, number> } }
|
||||
// Only the ferry question gets a usable answer, so the first two fall through.
|
||||
if (!('use_ferry' in body.costing_options.auto)) {
|
||||
return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true }))
|
||||
}
|
||||
return HttpResponse.json(valhallaAnswer({ has_ferry: false }))
|
||||
})
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('ferry')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => {
|
||||
// The reason the geometry decides rather than the numbers. Valhalla prices roads
|
||||
// differently, so on a long leg the SAME road comes back kilometres apart in both
|
||||
// length and time — here 13 km and 5 minutes, which is the measured spread between
|
||||
// the two engines and comfortably past the 200 m the numbers used to allow. Judged
|
||||
// on those it reads as a discovery and puts a second blue line on top of the first.
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json({
|
||||
trip: {
|
||||
// twoRoutes.routes[0]'s own line, re-encoded: the identical road.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }],
|
||||
summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false },
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => {
|
||||
let valhallaCalls = 0
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } }))
|
||||
server.use(
|
||||
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}),
|
||||
http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) })
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 })
|
||||
|
||||
expect(valhallaCalls).toBe(0)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
|
||||
// OSRM reports these in every answer and TREK threw them away since the first route
|
||||
// was drawn. Without them a place set back from the road looks like it is on the
|
||||
|
||||
@@ -3,6 +3,9 @@ import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
|
||||
import type { RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
@@ -50,6 +53,44 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
||||
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch`, with the request counted.
|
||||
*
|
||||
* Every route TREK draws goes out from here, and nowhere else, which makes this the one
|
||||
* place that can answer how much routing an instance really does — the number behind
|
||||
* "could we host an engine ourselves". Counted: how many requests, of what kind, how
|
||||
* many waypoints, roughly how far, and whether the host refused. Not counted, because it
|
||||
* is never sent: where any of it was.
|
||||
*
|
||||
* Distance is the straight line along the waypoint chain rather than the routed length,
|
||||
* which is only in the answer and differs per response shape. It is a floor on the real
|
||||
* figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one.
|
||||
*
|
||||
* An aborted request is not a failure: the map cancels constantly while someone drags.
|
||||
*/
|
||||
async function routedFetch(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
kind: RouteUsageSurface,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
waypoints: readonly Waypoint[],
|
||||
): Promise<Response> {
|
||||
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
let km = 0
|
||||
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
|
||||
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
|
||||
try {
|
||||
const response = await fetch(url, { signal })
|
||||
countRoute({ ...sample, failed: !response.ok })
|
||||
return response
|
||||
} catch (err) {
|
||||
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
||||
countRoute({ ...sample, failed: true })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A routing request the host refused, as opposed to one it answered with "no route".
|
||||
*
|
||||
@@ -114,7 +155,7 @@ export async function calculateRoute(
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'route', profile, waypoints)
|
||||
if (!response.ok) {
|
||||
throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
}
|
||||
@@ -321,7 +362,7 @@ export async function calculateSegments(
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
@@ -352,23 +393,55 @@ export async function calculateSegments(
|
||||
*/
|
||||
export async function calculateRouteWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
|
||||
{ signal, profile = 'driving', tripId, dayId, avoid = [] }: {
|
||||
signal?: AbortSignal
|
||||
profile?: RouteProfileKey
|
||||
tripId?: number | string | null
|
||||
dayId?: number | null
|
||||
/** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */
|
||||
avoid?: readonly AvoidClass[]
|
||||
} = {}
|
||||
): Promise<RouteWithLegs> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort()
|
||||
: []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
// The cached result carries formatted leg distances, so the active distance unit is
|
||||
// part of the key — otherwise switching km↔mi would return stale text (#1300).
|
||||
// A plugin route is trip-/day-specific (it may return different charging stops for
|
||||
// the same coordinates on a different day), so its key includes tripId/dayId;
|
||||
// the built-in OSRM profiles are context-free and leave those out.
|
||||
//
|
||||
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
|
||||
// turning the switch on returns the tolled route out of cache in under a millisecond,
|
||||
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
|
||||
// way is worse: this Map is shared with the ordinary day planner, the studio and the
|
||||
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
|
||||
// under the plain key would hand them a road nobody drives and a second engine's
|
||||
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
|
||||
// also separates the two engines.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
|
||||
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
|
||||
// day drawn rather than blank, and `avoidance` stays absent on that result so the
|
||||
// rail can say the avoidance did not happen instead of implying it did.
|
||||
if (routed) {
|
||||
cacheRoute(cacheKey, routed)
|
||||
return routed
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
|
||||
// and normalizes its answer; null means the provider failed or refused, and the
|
||||
// throw makes callers fall back to straight lines exactly like an OSRM outage.
|
||||
@@ -416,7 +489,7 @@ export async function calculateRouteWithLegs(
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
@@ -446,12 +519,73 @@ export async function calculateRouteWithLegs(
|
||||
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
routeCache.set(cacheKey, result)
|
||||
cacheRoute(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
routeCache.set(key, route)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain from the second engine, weighted away from some road classes.
|
||||
*
|
||||
* Shaped exactly like the OSRM answer above so the callers never branch on which engine
|
||||
* spoke — same leg list, same formatted text, same snap positions. What it adds is
|
||||
* `avoidance`: what was asked for against what the road actually turned out to be, which
|
||||
* is the only honest way to label a weighting that can fail.
|
||||
*
|
||||
* Null rather than a throw when it cannot answer, so the caller falls through to OSRM
|
||||
* and the day stays drawn.
|
||||
*/
|
||||
async function routeAvoidingWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
avoid: AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteWithLegs | null> {
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: waypoints.length,
|
||||
km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0),
|
||||
}
|
||||
const run = await valhallaRun(waypoints, 'driving', avoid, signal)
|
||||
// Counted by hand because the adapter speaks to a different engine with a different
|
||||
// verb, and the counter exists to answer "how much routing does this instance do" —
|
||||
// an engine it cannot see is exactly the traffic that would go missing from it.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
|
||||
if (!run) return null
|
||||
|
||||
const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from,
|
||||
to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(leg.distance / (5000 / 3600)),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
durationText: formatDuration(leg.duration),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
coordinates: run.total.coordinates,
|
||||
distance: run.total.distance,
|
||||
duration: run.total.duration,
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,7 +648,7 @@ export interface RouteAlternative {
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: 'motorway' | 'toll'
|
||||
avoids?: AvoidClass
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -528,11 +662,39 @@ export interface RouteAlternative {
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*
|
||||
* Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM
|
||||
* does carry a ferry class, but on a leg where a ferry is the only crossing it answers
|
||||
* NoRoute rather than the long way round, which is not an alternative anybody can use.
|
||||
* Valhalla weights it instead and drives around, which is the whole point.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
|
||||
|
||||
/** Two lines are the same road if their length and their duration agree closely enough. */
|
||||
/**
|
||||
* How far two lines may stray from each other and still be the same road, in kilometres.
|
||||
*
|
||||
* Parallel carriageways, a different slip road onto the same motorway and the noise
|
||||
* between two engines' idea of where a road is all sit well under this. A way somebody
|
||||
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
|
||||
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
|
||||
*/
|
||||
const SAME_ROAD_KM = 0.5
|
||||
|
||||
/**
|
||||
* Two lines are the same road.
|
||||
*
|
||||
* Length and duration used to decide this, and stopped being enough when a second engine
|
||||
* started answering. Valhalla prices roads differently — measured over ten European legs
|
||||
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
|
||||
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
|
||||
* Two identical blue lines on the map is exactly what that looks like.
|
||||
*
|
||||
* The geometry does not care which engine drew it, so it decides whenever there is one.
|
||||
* The numbers stay as the fallback for a line with no shape to compare.
|
||||
*/
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
const stray = furthestFrom(b.coordinates, a.coordinates)
|
||||
if (stray) return stray.km < SAME_ROAD_KM
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
@@ -563,16 +725,76 @@ const excludeUnsupported = new Set<string>()
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
coords: string,
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: string,
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
// Valhalla first, because on a default install it is the only one of the two that
|
||||
// can answer at all. It weights the class away instead of banning it, so it also
|
||||
// answers on a leg where the class cannot be avoided — and says so, which is why the
|
||||
// result is thrown away below unless the road really came back without it.
|
||||
const weighted = await valhallaExcluding(from, to, profile, exclude, signal)
|
||||
if (weighted) return weighted
|
||||
|
||||
// OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers
|
||||
// NoRoute rather than driving round, so there is nothing to offer and no point paying
|
||||
// a request to be told so.
|
||||
if (exclude === 'ferry') return null
|
||||
return osrmExcluding(from, to, profile, exclude, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same leg from Valhalla, weighted away from one class, or null.
|
||||
*
|
||||
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
|
||||
* between two points with no untolled connection the answer is still a tolled road.
|
||||
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
|
||||
* OSRM attempt gets its turn.
|
||||
*/
|
||||
async function valhallaExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
if (!valhallaAvailable()) return null
|
||||
const sample = {
|
||||
profile,
|
||||
surface: 'alternatives' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: 2,
|
||||
km: haversineKm(from, to),
|
||||
}
|
||||
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal)
|
||||
// Counted here rather than in the adapter, so the adapter stays a format translator
|
||||
// and every routing request an instance makes still lands in the one counter.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
|
||||
if (!leg || !legAvoids(leg, exclude)) return null
|
||||
return {
|
||||
coordinates: leg.coordinates,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
divergence: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The same leg from OSRM with the class excluded outright, or null. */
|
||||
async function osrmExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
const base = routeBaseFor(profile)
|
||||
if (excludeUnsupported.has(base)) return null
|
||||
try {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
|
||||
if (!response.ok) {
|
||||
// 400 is the router saying the parameter itself is not available here, which is
|
||||
// true of every leg from now on. Anything else is about this request alone.
|
||||
@@ -614,7 +836,7 @@ export async function calculateAlternatives(
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
@@ -629,19 +851,18 @@ export async function calculateAlternatives(
|
||||
|
||||
// Nothing but the router's own preference came back, which is what it answers on most
|
||||
// long legs. Rather than reporting "only one sensible way", ask a different question:
|
||||
// the same drive without the motorway, and failing that without the tolls. Tried one at
|
||||
// a time and stopped as soon as one lands, so a leg that does have a second road costs
|
||||
// one extra request rather than three.
|
||||
// the same drive without the motorway, then without the tolls, then without a ferry.
|
||||
// Tried one at a time and stopped as soon as one lands, so a leg that does have a
|
||||
// second road costs one extra request rather than three.
|
||||
//
|
||||
// Worth knowing before reading further: on the two public hosts TREK ships with, this
|
||||
// never produces anything. Their car profile is built without excludable classes and
|
||||
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
|
||||
// way to drive them. It works against a self-hosted OSRM built through the MLD
|
||||
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
|
||||
// This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP
|
||||
// 400 on both public hosts, whose car profile is built without excludable classes. It
|
||||
// now asks Valhalla first, where avoidance is a runtime weighting, and only falls back
|
||||
// to OSRM's `exclude` for an instance running its own MLD-built router.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(coords, profile, exclude, signal)
|
||||
const detour = await routeExcluding(from, to, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
@@ -659,22 +880,43 @@ export async function calculateAlternatives(
|
||||
|
||||
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
|
||||
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
|
||||
const furthest = furthestFrom(line, reference)
|
||||
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same point, plus how far off it actually is.
|
||||
*
|
||||
* The search itself stays in squared degrees with a cosine on the longitude: it only has
|
||||
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
|
||||
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
|
||||
* real distance rather than a rank, and half a degree is a different number of metres in
|
||||
* Andalusia than in Lapland.
|
||||
*/
|
||||
function furthestFrom(
|
||||
line: [number, number][],
|
||||
reference: [number, number][],
|
||||
): { lat: number; lng: number; km: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestNeighbour: [number, number] | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
let nearestPoint: [number, number] | null = null
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) nearest = d
|
||||
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
|
||||
}
|
||||
return best
|
||||
if (!best) return null
|
||||
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { ...best, km }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from './TripRouteOverview'
|
||||
import type { TripRouteOverview } from './useTripRouteOverview'
|
||||
|
||||
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
|
||||
days: [
|
||||
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
|
||||
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
|
||||
],
|
||||
lines: [], lineColors: [], segments: [], focusPoints: [],
|
||||
totalDistance: 222500, totalDuration: 10800, loading: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('TripRouteOverviewPill', () => {
|
||||
it('FE-MAP-TROU-001: says which way it will take you, and reports its state', () => {
|
||||
const onToggle = vi.fn()
|
||||
const { rerender } = render(<TripRouteOverviewPill active={false} onToggle={onToggle} />)
|
||||
|
||||
const button = screen.getByTestId('trip-overview-pill')
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(button).toHaveAttribute('aria-label', 'map.overview.show')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<TripRouteOverviewPill active onToggle={onToggle} />)
|
||||
expect(screen.getByTestId('trip-overview-pill')).toHaveAttribute('aria-label', 'map.overview.hide')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TripRouteOverviewPanel', () => {
|
||||
it('FE-MAP-TROU-002: leads with the trip total in the reader\'s own unit', () => {
|
||||
const { rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(screen.getByText('222.5 km')).toBeInTheDocument()
|
||||
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="imperial" />)
|
||||
expect(screen.getByText('138.3 mi')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-003: names every day, its distance and how it is travelled', () => {
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
|
||||
expect(screen.getByText('dayplan.dayN')).toBeInTheDocument()
|
||||
// A day with a title is named by it rather than by its number.
|
||||
expect(screen.getByText('Coast road')).toBeInTheDocument()
|
||||
expect(screen.getByText('82 km')).toBeInTheDocument()
|
||||
expect(screen.getByText('140.5 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-004: marks the total as partial while legs are still coming in', () => {
|
||||
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview({ loading: true })} unit="metric" />)
|
||||
expect(container.textContent).toContain('…')
|
||||
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(container.textContent).not.toContain('…')
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-005: a day row picks that day when the shell offers it', () => {
|
||||
const onSelectDay = vi.fn()
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" selectedDayId={2} onSelectDay={onSelectDay} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Coast road'))
|
||||
expect(onSelectDay).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-006: read-only shells get rows, not buttons', () => {
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-008: hugs its rows, and takes the cap the shell gives it', () => {
|
||||
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
const card = () => screen.getByTestId('trip-overview-panel')
|
||||
|
||||
expect(card().style.width).toBe('fit-content')
|
||||
expect(card().style.maxWidth).toBe('320px')
|
||||
|
||||
// The phone shell asks for a narrower card so the map keeps the space.
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" maxWidth={240} />)
|
||||
expect(card().style.maxWidth).toBe('240px')
|
||||
expect(container).not.toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-007: draws nothing at all for a trip with no routed day', () => {
|
||||
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
|
||||
/**
|
||||
* Toggle for the whole-trip route overview (#1736). Same frosted shell as the other
|
||||
* map controls, so it lines up with the compass and the layer switcher.
|
||||
*/
|
||||
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const label = active ? t('map.overview.hide') : t('map.overview.show')
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
data-testid="trip-overview-pill"
|
||||
className={active ? 'text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', padding: 0,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Route size={17} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's stages and its total distance, read off the overview the map is drawing.
|
||||
*
|
||||
* One component for both shells: the phone and the desktop place it differently but the
|
||||
* rows are the same rows, and a second copy of them is a second thing to keep in step.
|
||||
*/
|
||||
export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelectDay, maxWidth = 320 }: {
|
||||
overview: Overview
|
||||
unit: DistanceUnit
|
||||
selectedDayId?: number | null
|
||||
onSelectDay?: (dayId: number) => void
|
||||
/** How wide the card may grow before day names start to ellipsize. */
|
||||
maxWidth?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!overview.days.length) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="trip-overview-panel"
|
||||
style={{
|
||||
pointerEvents: 'auto', borderRadius: 14, overflow: 'hidden',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
// Sized to its rows rather than to the space available: a trip of short day
|
||||
// names left a band of empty card over the map, which on a phone is most of
|
||||
// what there is to look at.
|
||||
width: 'fit-content', maxWidth,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '10px 14px 8px' }}>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{t('map.overview.total')}
|
||||
</span>
|
||||
{/* Same tier as the label beside it: the two read as one line rather than as a
|
||||
heading with a number stuck under it. */}
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
|
||||
{formatDistance(overview.totalDistance / 1000, unit)}
|
||||
{/* Still routing: the number is a partial sum, and saying so beats a total
|
||||
that silently grows while you read it. */}
|
||||
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
|
||||
{overview.days.map(day => {
|
||||
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
|
||||
const row = (
|
||||
<>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
|
||||
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
|
||||
{day.modes.map(mode => {
|
||||
const Icon = profileIcon(mode)
|
||||
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
|
||||
})}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatDistance(day.distance / 1000, unit)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const style = {
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 14px', textAlign: 'left' as const,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent',
|
||||
border: 'none',
|
||||
}
|
||||
return onSelectDay ? (
|
||||
<button key={day.dayId} type="button" onClick={() => onSelectDay(day.dayId)} style={{ ...style, cursor: 'pointer' }}>
|
||||
{row}
|
||||
</button>
|
||||
) : (
|
||||
<div key={day.dayId} style={style}>{row}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import type * as L from 'leaflet'
|
||||
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap'
|
||||
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import {
|
||||
VectorBasemap,
|
||||
attachVectorBasemap,
|
||||
detachBasemapLayer,
|
||||
hideLabelLayers,
|
||||
restyleBasemap,
|
||||
type BasemapLayer,
|
||||
type GlLeafletLayer,
|
||||
} from './VectorBasemap'
|
||||
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION, RASTER_FALLBACK_TILE_URL } from '../../constants/mapDefaults'
|
||||
|
||||
const addAttribution = vi.fn()
|
||||
/** Only the two things the component touches; casting keeps the mock honest about that. */
|
||||
@@ -20,7 +29,16 @@ const leafletMap = asMap({ attributionControl: { addAttribution } })
|
||||
/** A map built without an attribution control, like the atlas. */
|
||||
const bareMap = asMap({})
|
||||
|
||||
vi.mock('react-leaflet', () => ({ useMap: () => leafletMap }))
|
||||
// TileLayer as well as useMap: the component renders one when the browser has no
|
||||
// WebGL, and jsdom is exactly such a browser.
|
||||
vi.mock('react-leaflet', () => ({
|
||||
useMap: () => leafletMap,
|
||||
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-basemap" data-url={url} />,
|
||||
}))
|
||||
|
||||
// jsdom has no WebGL, so without this every case below would take the raster
|
||||
// branch. The suite is about the GL wiring; the raster branch has its own cases.
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
|
||||
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
|
||||
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
||||
const maplibreGL = vi.fn(() => {
|
||||
@@ -201,3 +219,91 @@ describe('hideLabelLayers', () => {
|
||||
expect(() => hideLabelLayers(layer)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A browser that will not give MapLibre a WebGL context (#2288).
|
||||
*
|
||||
* Hardware acceleration off, a blocklisted driver, a VM or a remote desktop all
|
||||
* land here. Before the probe, the throw came out of an async effect nobody could
|
||||
* catch, and the layer it left behind took the page down on the way out.
|
||||
*/
|
||||
describe('VectorBasemap without WebGL', () => {
|
||||
const noWebGL = vi.mocked(hasWebGL)
|
||||
|
||||
/** A map real Leaflet will accept, which the raster stand-in needs. */
|
||||
const rasterMap = () => asMap({ addLayer: vi.fn(), attributionControl: { addAttribution } })
|
||||
|
||||
beforeEach(() => {
|
||||
noWebGL.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-013: draws raster tiles and never asks for the maplibre chunk', async () => {
|
||||
// Not loading it is half the point: about a megabyte for a browser that could
|
||||
// never have drawn it.
|
||||
const { findByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
|
||||
const tiles = await findByTestId('raster-basemap')
|
||||
expect(tiles.getAttribute('data-url')).toBe(RASTER_FALLBACK_TILE_URL)
|
||||
expect(maplibreGL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-014: unmounting is quiet, with nothing to take off the map', () => {
|
||||
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
// The crash in #2288 came from teardown, not from the failed attach.
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-015: the imperative maps get the raster stand-in in their ref', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
const map = rasterMap()
|
||||
|
||||
await attachVectorBasemap(map, OFM_POSITRON, ref, () => false)
|
||||
|
||||
expect(maplibreGL).not.toHaveBeenCalled()
|
||||
expect(ref.current).not.toBeNull()
|
||||
// OpenStreetMap rather than OpenFreeMap: different tiles, different credit.
|
||||
expect(addAttribution).toHaveBeenCalledWith(expect.stringContaining('openstreetmap.org/copyright'))
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-016: a map torn down mid-probe still gets nothing', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => true)
|
||||
expect(ref.current).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-017: the raster stand-in ignores a restyle and a label sweep', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => false)
|
||||
|
||||
// Both are GL-only operations. Called on raster tiles they must do nothing
|
||||
// rather than reach for a maplibre map that is not there.
|
||||
expect(() => restyleBasemap(ref.current, OFM_DARK)).not.toThrow()
|
||||
expect(() => hideLabelLayers(ref.current!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detachBasemapLayer', () => {
|
||||
it('FE-COMP-VECBM-018: a GL layer whose context never came off cleanly anyway', () => {
|
||||
// This is the crash from #2288 in one line: maplibre-gl-leaflet's onRemove
|
||||
// calls this._glMap.remove() without asking whether there is one, and after a
|
||||
// refused context there is not. It ran inside a React cleanup, so the throw
|
||||
// reached a boundary and replaced the page.
|
||||
const remove = vi.fn(function (this: { _glMap?: { remove: () => void } }) {
|
||||
this._glMap!.remove()
|
||||
})
|
||||
const halfBuilt = { getMaplibreMap: () => undefined, remove } as unknown as GlLeafletLayer
|
||||
|
||||
expect(() => detachBasemapLayer(halfBuilt)).not.toThrow()
|
||||
expect(remove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-019: a layer that throws for any other reason is swallowed too', () => {
|
||||
// Unmounting a map is never allowed to fail because its basemap did.
|
||||
const layer = { remove: vi.fn(() => { throw new Error('boom') }) } as unknown as BasemapLayer
|
||||
expect(() => detachBasemapLayer(layer)).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-020: nothing to detach is not an error', () => {
|
||||
expect(() => detachBasemapLayer(null)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import type * as L from 'leaflet'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TileLayer, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { attributionForTile } from '../../constants/mapDefaults'
|
||||
import {
|
||||
RASTER_FALLBACK_MAX_ZOOM,
|
||||
RASTER_FALLBACK_TILE_URL,
|
||||
attributionForTile,
|
||||
} from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
||||
|
||||
/**
|
||||
* What a map ended up drawing with. Every caller keeps one ref for both kinds, so
|
||||
* there is no code path that knows only about the GL layer and falls over the
|
||||
* raster stand-in the moment a browser has no WebGL (#2288).
|
||||
*/
|
||||
export type BasemapLayer = GlLeafletLayer | L.TileLayer
|
||||
|
||||
/** A GL layer rather than the raster stand-in. Prototype method, so `in` finds it. */
|
||||
function isGlLayer(layer: BasemapLayer): layer is GlLeafletLayer {
|
||||
return 'getMaplibreMap' in layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the layer factory.
|
||||
*
|
||||
@@ -28,11 +46,105 @@ async function loadLayerFactory() {
|
||||
return bridge.maplibreGL
|
||||
}
|
||||
|
||||
/** The factory, or null when its chunk never arrived. A dead chunk is not the basemap's problem. */
|
||||
async function loadLayerFactoryOrNull() {
|
||||
try {
|
||||
return await loadLayerFactory()
|
||||
} catch (err) {
|
||||
// A chunk gone after a deploy is the global handler's case and heals itself
|
||||
// with one reload; here it only means there is no GL layer to attach.
|
||||
if (isChunkLoadError(err)) reloadOnceForChunk()
|
||||
else console.warn('[basemap] the maplibre chunk did not load', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Credit the basemap on a map that has an attribution control; a no-op on one that does not. */
|
||||
function creditBasemap(map: L.Map, style: string): void {
|
||||
map.attributionControl?.addAttribution(attributionForTile(style))
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a half-built GL layer safe to take off a map.
|
||||
*
|
||||
* maplibre-gl-leaflet's onRemove calls `this._glMap.remove()` without asking
|
||||
* whether there is one, and after a refused WebGL context there is not. That
|
||||
* second throw is what turned a missing basemap into a crash: it lands in a React
|
||||
* cleanup, where a boundary picks it up and replaces the page (#2288). A no-op
|
||||
* stand-in lets the bridge run the rest of its own teardown, the pane child and
|
||||
* the zoom-animation listener, and skip only the line that cannot work. A healthy
|
||||
* layer already has a GL map and is left alone.
|
||||
*/
|
||||
function disarmGlLayer(layer: GlLeafletLayer): void {
|
||||
const internals = layer as unknown as { _glMap?: { remove: () => void } | null }
|
||||
if (!internals._glMap) internals._glMap = { remove: () => {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang a GL layer into a Leaflet map and say whether it took.
|
||||
*
|
||||
* maplibre-gl builds its WebGL context inside the Map constructor, which Leaflet
|
||||
* reaches through onAdd, so `addTo` is where a browser without WebGL throws. The
|
||||
* probe catches nearly every case; what is left is a context budget that ran out
|
||||
* between probe and attach, which is real on a page holding several maps. Either
|
||||
* way the layer has to come back off: Leaflet registers it before onAdd runs, so
|
||||
* a broken one left behind is handed to the retile effect and to every later
|
||||
* map.remove().
|
||||
*/
|
||||
function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolean {
|
||||
try {
|
||||
layer.addTo(map)
|
||||
} catch (err) {
|
||||
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
|
||||
detachBasemapLayer(layer)
|
||||
return false
|
||||
}
|
||||
creditBasemap(map, style)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The raster stand-in, attached imperatively for the maps that build Leaflet
|
||||
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
|
||||
* below instead, the way they already draw a user's own raster template.
|
||||
*/
|
||||
function attachRasterFallback(map: L.Map): L.TileLayer {
|
||||
const layer = L.tileLayer(RASTER_FALLBACK_TILE_URL, {
|
||||
maxZoom: RASTER_FALLBACK_MAX_ZOOM,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
} as L.TileLayerOptions)
|
||||
layer.addTo(map)
|
||||
creditBasemap(map, RASTER_FALLBACK_TILE_URL)
|
||||
return layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a basemap off its map. Never throws: unmounting a map is not allowed to
|
||||
* fail because its basemap did (#2288).
|
||||
*/
|
||||
export function detachBasemapLayer(layer: BasemapLayer | null | undefined): void {
|
||||
if (!layer) return
|
||||
if (isGlLayer(layer)) disarmGlLayer(layer)
|
||||
try {
|
||||
layer.remove()
|
||||
} catch (err) {
|
||||
console.warn('[basemap] teardown failed, leaving the layer where it is', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retile in place. Swapping the layer would drop a WebGL context per theme
|
||||
* toggle, and browsers cap those in the low teens.
|
||||
*
|
||||
* The raster stand-in has nothing to swap: it draws one template, and there is no
|
||||
* keyless dark OSM raster to switch to, so a theme change leaves it alone rather
|
||||
* than refetching the same tiles.
|
||||
*/
|
||||
export function restyleBasemap(layer: BasemapLayer | null | undefined, style: string): void {
|
||||
if (!layer || !isGlLayer(layer)) return
|
||||
layer.getMaplibreMap()?.setStyle(style)
|
||||
}
|
||||
|
||||
/**
|
||||
* A MapLibre vector style as the basemap of a Leaflet map.
|
||||
*
|
||||
@@ -40,21 +152,37 @@ function creditBasemap(map: L.Map, style: string): void {
|
||||
* watermark unless the operator has requested a key by mail, so the basemap of
|
||||
* every Leaflet map here is a style document rather than a tile template.
|
||||
* maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which
|
||||
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers
|
||||
* and the panes they live in are untouched, and Leaflet's CSS puts
|
||||
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers and
|
||||
* the panes they live in are untouched, and Leaflet's CSS puts
|
||||
* `pointer-events: none` on the layer's canvas, so clicks fall through to them.
|
||||
*
|
||||
* WebGL is not a given, though. Hardware acceleration switched off, a blocklisted
|
||||
* driver, a VM or a remote desktop all end with maplibre-gl throwing out of its
|
||||
* constructor, so the browser is asked first and gets raster tiles when the answer
|
||||
* is no (#2288). Asking is also what keeps the maplibre chunk off the wire for a
|
||||
* browser that could never have used it.
|
||||
*/
|
||||
export function VectorBasemap({ style }: { style: string }): null {
|
||||
export function VectorBasemap({ style }: { style: string }) {
|
||||
const map = useMap()
|
||||
const layerRef = useRef<GlLeafletLayer | null>(null)
|
||||
const styleRef = useRef(style)
|
||||
styleRef.current = style
|
||||
// State rather than a plain constant: the probe can say yes and the attach can
|
||||
// still fail once the page has spent its context budget, and that has to reach
|
||||
// the render or the raster tiles never appear.
|
||||
const [glUsable, setGlUsable] = useState(hasWebGL)
|
||||
|
||||
useEffect(() => {
|
||||
if (!glUsable) return
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
const maplibreGL = await loadLayerFactoryOrNull()
|
||||
if (cancelled) return
|
||||
if (!maplibreGL) {
|
||||
setGlUsable(false)
|
||||
return
|
||||
}
|
||||
|
||||
// The style is read through a ref, never from the dependency list: a style
|
||||
// change must retile in place rather than tear the layer down, the same
|
||||
@@ -64,68 +192,106 @@ export function VectorBasemap({ style }: { style: string }): null {
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
if (!attachGlLayer(map, layer, styleRef.current)) {
|
||||
setGlUsable(false)
|
||||
return
|
||||
}
|
||||
layerRef.current = layer
|
||||
layer.addTo(map)
|
||||
creditBasemap(map, styleRef.current)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
layerRef.current?.remove()
|
||||
detachBasemapLayer(layerRef.current)
|
||||
layerRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
}, [map, glUsable])
|
||||
|
||||
// Restyle in place. Swapping the layer would drop a WebGL context per theme
|
||||
// toggle, and browsers cap those in the low teens.
|
||||
useEffect(() => {
|
||||
layerRef.current?.getMaplibreMap()?.setStyle(style)
|
||||
restyleBasemap(layerRef.current, style)
|
||||
}, [style])
|
||||
|
||||
// No WebGL, no vector tiles. Raster keeps the map readable instead of leaving
|
||||
// markers, routes and clusters floating over grey; see mapDefaults for why this
|
||||
// particular source. The satellite toggle in MapView was always raster and is
|
||||
// unaffected either way.
|
||||
if (!glUsable) {
|
||||
return (
|
||||
<TileLayer
|
||||
key="webgl-fallback"
|
||||
url={RASTER_FALLBACK_TILE_URL}
|
||||
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
|
||||
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default VectorBasemap
|
||||
|
||||
/**
|
||||
* The same layer for the maps that build Leaflet imperatively rather than
|
||||
* The same basemap for the maps that build Leaflet imperatively rather than
|
||||
* through react-leaflet: the journey map and the atlas.
|
||||
*
|
||||
* Loading is async because maplibre-gl only ever arrives through a dynamic
|
||||
* import, so the caller passes a `cancelled` probe: a map torn down mid-load
|
||||
* must not get a layer attached to it afterwards.
|
||||
* Loading is async because maplibre-gl only ever arrives through a dynamic import,
|
||||
* so the caller passes a `cancelled` probe: a map torn down mid-load must not get
|
||||
* a layer attached to it afterwards. What lands in `ref` is either the GL layer or
|
||||
* the raster stand-in, which is why callers go through restyleBasemap() and
|
||||
* detachBasemapLayer() rather than reaching for getMaplibreMap() themselves.
|
||||
*/
|
||||
export async function attachVectorBasemap(
|
||||
map: L.Map,
|
||||
style: string,
|
||||
ref: { current: GlLeafletLayer | null },
|
||||
ref: { current: BasemapLayer | null },
|
||||
cancelled: () => boolean,
|
||||
opts: { hideLabels?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
// Same gate as the component: a browser without WebGL never pays for the
|
||||
// maplibre chunk and gets raster tiles rather than an empty map (#2288).
|
||||
if (!hasWebGL()) {
|
||||
if (!cancelled()) ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
|
||||
const maplibreGL = await loadLayerFactoryOrNull()
|
||||
if (cancelled()) return
|
||||
if (!maplibreGL) {
|
||||
ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
|
||||
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
|
||||
ref.current = layer
|
||||
layer.addTo(map)
|
||||
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
|
||||
// one of its own.
|
||||
creditBasemap(map, style)
|
||||
// one of its own; attachGlLayer adds it once the layer is actually on the map.
|
||||
if (!attachGlLayer(map, layer, style)) {
|
||||
ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
ref.current = layer
|
||||
if (opts.hideLabels) hideLabelLayers(layer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every label from a vector style.
|
||||
*
|
||||
* The atlas draws country names into its own fills, so a basemap that repeats
|
||||
* them underneath is noise. OpenFreeMap has no label-free style, but its labels
|
||||
* are all `symbol` layers, while borders and coastlines are `line` and `fill`,
|
||||
* so hiding that one type leaves the geography intact.
|
||||
* The atlas draws country names into its own fills, so a basemap that repeats them
|
||||
* underneath is noise. OpenFreeMap has no label-free style, but its labels are all
|
||||
* `symbol` layers, while borders and coastlines are `line` and `fill`, so hiding
|
||||
* that one type leaves the geography intact.
|
||||
*
|
||||
* Bound to `style.load` rather than run once: the event fires again after every
|
||||
* setStyle, so a theme switch keeps the labels off without a second call site.
|
||||
*/
|
||||
export function hideLabelLayers(layer: GlLeafletLayer): void {
|
||||
export function hideLabelLayers(layer: BasemapLayer): void {
|
||||
// Nothing to hide on the raster stand-in: OSM bakes its labels into the tile.
|
||||
// A WebGL-less atlas therefore shows country names twice, which is cosmetic and
|
||||
// the price of having a basemap at all, because the only keyless alternative is
|
||||
// none.
|
||||
if (!isGlLayer(layer)) return
|
||||
const gl = layer.getMaplibreMap()
|
||||
if (!gl) return
|
||||
const apply = () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns } from './dayRoutePlan'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ day_id: 1, order_index: order, place: buildPlace({ lat, lng }), ...extra })
|
||||
|
||||
const inputs = (over: Partial<{
|
||||
days: Day[]; assignments: AssignmentsMap; reservations: Reservation[]
|
||||
accommodations: Accommodation[]; optimizeFromAccommodation: boolean | undefined
|
||||
}> = {}) => ({
|
||||
days: [buildDay({ id: 1, day_number: 1 })],
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
accommodations: [],
|
||||
optimizeFromAccommodation: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('buildDayRouteRuns', () => {
|
||||
it('FE-MAP-DRP-001: orders a day\'s located stops into one run', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 1), at(45.76, 4.83, 0)] },
|
||||
}))
|
||||
|
||||
expect(runs).toHaveLength(1)
|
||||
// order_index, not the order they happen to sit in the array.
|
||||
expect(runs[0].map(p => p.lat)).toEqual([45.76, 48.86])
|
||||
expect(runs[0].every(p => p.isPlace)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-002: drops a stop with no coordinates', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: {
|
||||
'1': [
|
||||
at(48.86, 2.35, 0),
|
||||
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: null, lng: null }) }),
|
||||
at(45.76, 4.83, 2),
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(runs[0]).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-003: a lone stop is no drive at all', () => {
|
||||
expect(buildDayRouteRuns(1, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-004: carries the per-leg travel modes the router resolves against', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: {
|
||||
'1': [
|
||||
at(48.86, 2.35, 0, { leg_transport_mode: 'walking' }),
|
||||
at(48.88, 2.36, 1, { incoming_leg_transport_mode: 'cycling' }),
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(runs[0][0].leg_transport_mode).toBe('walking')
|
||||
expect(runs[0][1].incoming_leg_transport_mode).toBe('cycling')
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-005: two real places stay one run however far apart they are', () => {
|
||||
// Paris → Tokyo is far past MAX_DRIVE_KM, but both are real places someone
|
||||
// planned, so the run stands: the reachability guard only ever splits a leg
|
||||
// that touches a booking endpoint (#2133).
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(35.68, 139.69, 1)] },
|
||||
}))
|
||||
|
||||
expect(runs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-006: bookends the day with its accommodation when the setting is on', () => {
|
||||
const accommodation = {
|
||||
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
|
||||
start_day_id: 1, end_day_id: 1,
|
||||
} as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
|
||||
accommodations: [accommodation],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
|
||||
const flat = runs.flat()
|
||||
expect(flat.some(p => p.lat === 48.80 && !p.isPlace)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-007: leaves the hotel out when the setting is off', () => {
|
||||
const accommodation = {
|
||||
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
|
||||
start_day_id: 1, end_day_id: 1,
|
||||
} as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
|
||||
accommodations: [accommodation],
|
||||
optimizeFromAccommodation: false,
|
||||
}))
|
||||
|
||||
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { withHotelBookends } from './RouteCalculator'
|
||||
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
||||
|
||||
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
|
||||
export interface DayRoutePoint {
|
||||
lat: number
|
||||
lng: number
|
||||
isPlace: boolean
|
||||
leg_transport_mode?: string | null
|
||||
incoming_leg_transport_mode?: string | null
|
||||
}
|
||||
|
||||
/** Everything the plan depends on, passed in rather than read from the store, so the
|
||||
* builder stays pure and one caller can run it over every day of a trip at once. */
|
||||
export interface DayRouteInputs {
|
||||
days: Day[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
accommodations: Accommodation[]
|
||||
optimizeFromAccommodation: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The runs of waypoints a day's route is drawn from, hotel bookends folded in.
|
||||
*
|
||||
* Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the
|
||||
* same line the day view does. Every rule below is load-bearing and issue-numbered —
|
||||
* a second copy of it would drift the moment either surface got the next fix.
|
||||
*/
|
||||
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
|
||||
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
|
||||
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
return d ? (d.day_number ?? allDays.indexOf(d)) : null
|
||||
}
|
||||
const thisOrder = dayOrder(dayId)
|
||||
|
||||
// The order the day plan shows is the order the map has to draw, so take it from
|
||||
// the same place the plan does rather than rebuilding it here. getTransportForDay
|
||||
// brings the span filter, the hotel/assignment exclusions and the leg expansion with
|
||||
// it; getMergedItems places each leg by its own saved position, falling back to its
|
||||
// time. Notes carry no coordinates, so none are passed.
|
||||
const dayTransports = thisOrder == null ? [] : getTransportForDay({
|
||||
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
|
||||
dayId,
|
||||
dayAssignmentIds: da.map(a => a.id),
|
||||
days: allDays,
|
||||
})
|
||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
|
||||
const entries: Entry[] = merged.flatMap((item): Entry[] => {
|
||||
if (item.type === 'place') {
|
||||
const a = item.data
|
||||
if (!a.place?.lat || !a.place?.lng) return []
|
||||
return [{
|
||||
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
||||
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
|
||||
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
|
||||
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
|
||||
}]
|
||||
}
|
||||
if (item.type !== 'transport') return []
|
||||
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
|
||||
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
|
||||
})
|
||||
|
||||
// Group located places into driving runs. A transport WITH a location anchors the
|
||||
// route to its departure point then breaks the run (you don't drive the flight); its
|
||||
// arrival starts the next. A transport WITHOUT a location is ignored entirely. A run
|
||||
// is only a real drive when it contains at least one actual place, or two back-to-back
|
||||
// transports pair into a phantom [airport → airport] road route (#1394). A booking
|
||||
// endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival
|
||||
// sorted in among the day's local stops gets stapled to one of them (#2133).
|
||||
const runs: DayRoutePoint[][] = []
|
||||
let currentRun: DayRoutePoint[] = []
|
||||
let runHasPlace = false
|
||||
const closeRun = () => {
|
||||
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
|
||||
currentRun = []
|
||||
runHasPlace = false
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
// The open run may be nothing but a far-away arrival endpoint — break rather
|
||||
// than draw the ocean.
|
||||
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
|
||||
runHasPlace = true
|
||||
} else if (entry.from || entry.to) {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
|
||||
closeRun()
|
||||
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
|
||||
}
|
||||
}
|
||||
closeRun()
|
||||
|
||||
// Bookend the route with the day's accommodation, so the drawn line matches the
|
||||
// sidebar's hotel legs.
|
||||
const day = allDays.find(d => d.id === dayId)
|
||||
const bookends = day && optimizeFromAccommodation !== false
|
||||
? getDayBookendHotels(day, allDays, accommodations)
|
||||
: null
|
||||
const flatPts: DayRoutePoint[] = []
|
||||
for (const e of entries) {
|
||||
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
|
||||
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
|
||||
}
|
||||
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
||||
// falls through to the day default for hotel-adjacent legs unless the place endpoint
|
||||
// carries its own override.
|
||||
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
|
||||
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
|
||||
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
|
||||
// an arrival/departure day (#1321, #2133) all draw no bookend.
|
||||
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
|
||||
const firstStop = entries.find(contributes)
|
||||
const lastStop = [...entries].reverse().find(contributes)
|
||||
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
|
||||
if (!e) return undefined
|
||||
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
// check-out hotel its origin, which the no-time bookend default respects (#2157).
|
||||
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
|
||||
const firstWay = flatPts[0]
|
||||
const lastWay = flatPts[flatPts.length - 1]
|
||||
const morningHotel = hotelPt(bookends?.morning)
|
||||
const eveningHotel = hotelPt(bookends?.evening)
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
|
||||
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
|
||||
runs,
|
||||
firstWay,
|
||||
lastWay,
|
||||
drawMorning ? morningHotel : null,
|
||||
drawEvening ? eveningHotel : null,
|
||||
)
|
||||
|
||||
// Transfer day with no activities: you check out of one accommodation and into
|
||||
// another, so there are no waypoints for withHotelBookends to attach a leg to.
|
||||
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
|
||||
const m = hotelPt(bookends?.morning)
|
||||
const e = hotelPt(bookends?.evening)
|
||||
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
||||
}
|
||||
|
||||
return runsWithHotel
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The drop shadow every floating map control carries.
|
||||
*
|
||||
* Tighter and darker than `--sidebar-shadow`, which is built for a panel resting on
|
||||
* the app background: over map tiles its 0.10 alpha spread over 32px all but
|
||||
* disappears, so a round control read as painted onto the map rather than floating
|
||||
* above it. This is the locate button's shadow, which was always the odd one out for
|
||||
* exactly that reason — the others were the ones that needed to match it.
|
||||
*/
|
||||
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
|
||||
@@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => {
|
||||
attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled)
|
||||
expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI'])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// 50 px between the ends, under the 80 px car floor; the road loops out 300 px.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const bare = freshMap()
|
||||
attach(bare).update([hop(7, 'car', 0.05)], opts)
|
||||
expect(features(bare)).toHaveLength(0)
|
||||
|
||||
const routed = freshMap()
|
||||
attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]]))
|
||||
expect(features(routed)).toHaveLength(1)
|
||||
expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat]))
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
const map = freshMap()
|
||||
attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]]))
|
||||
expect(features(map)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReservationMapboxOverlay waypoint edge cases', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -259,27 +260,31 @@ export class ReservationMapboxOverlay {
|
||||
})
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
private linesFor(item: TransportItem): [number, number][][] {
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
private render() {
|
||||
const map = this.map
|
||||
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
|
||||
|
||||
const show = this.opts.showConnections
|
||||
|
||||
// Visible filter: require the on-screen pixel distance between
|
||||
// endpoints to exceed a type-specific minimum, same as the Leaflet
|
||||
// overlay, so tiny no-op transport lines don't clutter the map.
|
||||
// Visible filter: a hop draws once what it will draw is long enough on
|
||||
// screen to be worth it, same as the Leaflet overlay, so tiny no-op
|
||||
// transport lines don't clutter the map. Measured along the drawn line,
|
||||
// not between the ends: a routed drive can cover half the screen while
|
||||
// its endpoints sit close together (#2275).
|
||||
const project = (p: readonly [number, number]) => map.project([p[1], p[0]])
|
||||
const visibleItems = show ? this.items.filter(item => {
|
||||
try {
|
||||
// A transit journey draws its real alignment, not a straight from->to line, so
|
||||
// don't let the endpoint-proximity declutter hide it when the map is zoomed out
|
||||
// (#1570). Mirrors the Leaflet overlay.
|
||||
if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true
|
||||
const fromPx = map.project([item.from.lng, item.from.lat])
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return dist >= minPx
|
||||
return hopIsVisible(item.type, this.linesFor(item), project)
|
||||
} catch { return true }
|
||||
}) : []
|
||||
|
||||
@@ -293,8 +298,7 @@ export class ReservationMapboxOverlay {
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (dist >= minPx) labelVisibleIds.add(item.res.id)
|
||||
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -320,9 +324,7 @@ export class ReservationMapboxOverlay {
|
||||
}))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map(seg => ({
|
||||
return this.linesFor(item).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { countRoute, flushRouteUsage } from './routeUsageCounter'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do".
|
||||
*
|
||||
* Two things are worth pinning: that it batches rather than posting per route, because a
|
||||
* counter that costs a request of its own measures the load by adding to it, and that it
|
||||
* never throws — it sits on the hot path of every route the map draws.
|
||||
*/
|
||||
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: false,
|
||||
waypoints: 3,
|
||||
km: 100,
|
||||
failed: false,
|
||||
}
|
||||
|
||||
describe('routeUsageCounter', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
flushRouteUsage()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => {
|
||||
countRoute(sample)
|
||||
expect(routeUsageApi.report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => {
|
||||
countRoute(sample)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }])
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, waypoints: 5, km: 200 })
|
||||
countRoute({ ...sample, failed: true })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 })
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, surface: 'alternatives' })
|
||||
countRoute({ ...sample, selfHosted: true })
|
||||
countRoute({ ...sample, profile: 'walking' })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => {
|
||||
for (let i = 0; i < 25; i++) countRoute(sample)
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries[0].requests).toBe(25)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => {
|
||||
vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline'))
|
||||
countRoute(sample)
|
||||
expect(() => flushRouteUsage()).not.toThrow()
|
||||
await Promise.resolve()
|
||||
|
||||
countRoute(sample)
|
||||
flushRouteUsage()
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1]
|
||||
expect(entries[0].requests).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* Counts the routing this browser asks for, and posts the totals in batches.
|
||||
*
|
||||
* All routing happens client-side against an external engine, so the server never
|
||||
* sees a route request and cannot count one. Without a count nobody knows what a
|
||||
* self-hosted router would have to carry — how many requests a real trip costs, how
|
||||
* big they are, how much already goes to an operator's own engine.
|
||||
*
|
||||
* Counters only. What goes over the wire is four integers per bucket: how many
|
||||
* requests, how many waypoints, how many kilometres, how many failed. No coordinate,
|
||||
* no route, no trip, nothing that says where anybody went.
|
||||
*
|
||||
* Batched on purpose. A counter that cost a request of its own would measure the load
|
||||
* by adding to it, so the tally is held in memory and flushed on a timer, when it
|
||||
* grows past a threshold, and when the page goes away.
|
||||
*/
|
||||
|
||||
/** Flush when the tally has this many requests in it, whichever comes first. */
|
||||
const FLUSH_AFTER_REQUESTS = 25
|
||||
/** …or after this long, so a quiet session still reports what it did. */
|
||||
const FLUSH_AFTER_MS = 60_000
|
||||
/** Guard against a pathological session: drop the tally rather than grow forever. */
|
||||
const MAX_BUCKETS = 40
|
||||
|
||||
type Bucket = Omit<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
let pending = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let unloadWired = false
|
||||
|
||||
function flush(): void {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null }
|
||||
if (!buckets.size) return
|
||||
const entries = [...buckets.values()]
|
||||
buckets.clear()
|
||||
pending = 0
|
||||
// Fire and forget: a failed report is a lost count, not a problem worth telling
|
||||
// anybody about. The next flush carries on from an empty tally either way.
|
||||
void routeUsageApi.report(entries).catch(() => {})
|
||||
}
|
||||
|
||||
function wireUnload(): void {
|
||||
if (unloadWired || typeof document === 'undefined') return
|
||||
unloadWired = true
|
||||
// `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile,
|
||||
// where a session ends by switching apps rather than by closing a tab.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one routing request. Never throws and never awaits: it sits on the hot path
|
||||
* of every route the map draws.
|
||||
*/
|
||||
export function countRoute(sample: {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
waypoints: number
|
||||
km: number
|
||||
failed: boolean
|
||||
}): void {
|
||||
try {
|
||||
wireUnload()
|
||||
const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}`
|
||||
const bucket = buckets.get(key)
|
||||
if (bucket) {
|
||||
bucket.requests += 1
|
||||
bucket.waypoints += sample.waypoints
|
||||
bucket.km += sample.km
|
||||
if (sample.failed) bucket.failed += 1
|
||||
} else {
|
||||
if (buckets.size >= MAX_BUCKETS) return
|
||||
buckets.set(key, {
|
||||
profile: sample.profile,
|
||||
surface: sample.surface,
|
||||
selfHosted: sample.selfHosted,
|
||||
requests: 1,
|
||||
waypoints: sample.waypoints,
|
||||
km: sample.km,
|
||||
failed: sample.failed ? 1 : 0,
|
||||
})
|
||||
}
|
||||
pending += 1
|
||||
if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return }
|
||||
if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS)
|
||||
} catch { /* counting must never break routing */ }
|
||||
}
|
||||
|
||||
/** Flushes now. Exported for tests and for a deliberate "report before you go". */
|
||||
export function flushRouteUsage(): void {
|
||||
flush()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useMergedMapPois } from './useMergedMapPois'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
|
||||
*
|
||||
* The corridor answers "what is along the drive", the category pill answers "what is in
|
||||
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
|
||||
* only new when the content really is — the map rebuilds every pin off it.
|
||||
*/
|
||||
|
||||
const poi = (osm_id: string, name = osm_id): Poi => ({
|
||||
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
|
||||
} as Poi)
|
||||
|
||||
describe('useMergedMapPois', () => {
|
||||
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
|
||||
const explore = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(null, explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, []))
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
|
||||
const explore = [poi('n2')]
|
||||
const { result } = renderHook(() => useMergedMapPois([], explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
|
||||
// The corridor copy carries how far along the drive it sits, which the pill's does not.
|
||||
const corridor = [poi('n1', 'Hotel am Weg')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
|
||||
expect(result.current).toBe(corridor)
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].name).toBe('Hotel am Weg')
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const explore = [poi('n2')]
|
||||
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
|
||||
const first = result.current
|
||||
rerender()
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMergedMapPois — the refuel offers', () => {
|
||||
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
|
||||
|
||||
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
|
||||
// In road trip mode this is the only channel a POI reaches the map through, so
|
||||
// without it somebody is asked to accept a stop they cannot see.
|
||||
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('a')]
|
||||
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
|
||||
initialProps: { o: [] as Poi[] },
|
||||
})
|
||||
const first = result.current
|
||||
rerender({ o: [] })
|
||||
expect(result.current).toBe(first)
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* The findings the map should draw, when two searches can be running at once.
|
||||
*
|
||||
* In road trip mode the corridor search answers "what is along this drive". The category
|
||||
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
|
||||
* stop is exactly the case where someone wants the second one without giving up the first,
|
||||
* so the two lists are drawn together rather than one replacing the other.
|
||||
*
|
||||
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
|
||||
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
|
||||
* copy wins because it carries how far along the drive it sits.
|
||||
*
|
||||
* `offered` is the third case: the handful of filling stations proposed where a tank runs
|
||||
* out. They belong on the map for a plain reason — somebody is being asked to accept a
|
||||
* stop, and a stop you cannot see is not one you can judge — but they are not a search
|
||||
* result and must not survive the panel being closed, which is why they arrive separately
|
||||
* rather than being folded into the corridor's own list.
|
||||
*
|
||||
* Returns the input array itself whenever there is nothing to merge. The map redraws off
|
||||
* this reference, so handing it a fresh array on every render would rebuild every pin.
|
||||
*/
|
||||
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
|
||||
return useMemo(() => {
|
||||
const base = corridor === null ? explore : merge(corridor, explore)
|
||||
return merge(base, offered)
|
||||
}, [corridor, explore, offered])
|
||||
}
|
||||
|
||||
/** Nothing offered, as one stable array, so the default never changes identity. */
|
||||
const NONE: Poi[] = []
|
||||
|
||||
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
|
||||
function merge(a: Poi[], b: Poi[]): Poi[] {
|
||||
if (!b.length) return a
|
||||
if (!a.length) return b
|
||||
const seen = new Set(a.map(p => p.osm_id))
|
||||
const extra = b.filter(p => !seen.has(p.osm_id))
|
||||
return extra.length ? [...a, ...extra] : a
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import { useTripRouteOverview } from './useTripRouteOverview'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types'
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>()
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() }
|
||||
})
|
||||
|
||||
const { calculateRouteWithLegs } = await import('./RouteCalculator')
|
||||
|
||||
const leg = (distance: number, duration: number): RouteSegment => ({
|
||||
mid: [0, 0], from: [0, 0], to: [0, 0],
|
||||
distance, duration,
|
||||
distanceText: `${Math.round(distance / 1000)} km`,
|
||||
durationText: '1 h', walkingText: '5 h', drivingText: '1 h',
|
||||
})
|
||||
|
||||
const at = (lat: number, lng: number, order: number, dayId: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }), ...extra })
|
||||
|
||||
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
|
||||
const ASSIGNMENTS: AssignmentsMap = {
|
||||
'1': [at(48.86, 2.35, 0, 1), at(48.88, 2.36, 1, 1)],
|
||||
'2': [at(45.76, 4.83, 0, 2), at(45.78, 4.85, 1, 2)],
|
||||
}
|
||||
|
||||
const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days = DAYS) =>
|
||||
renderHook(
|
||||
({ assignments: a }: { assignments: AssignmentsMap }) =>
|
||||
useTripRouteOverview(7, days, a, [], [], 'driving', enabled),
|
||||
{ initialProps: { assignments } },
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(calculateRouteWithLegs).mockReset()
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
|
||||
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
|
||||
distance: 12000, duration: 900,
|
||||
legs: [leg(12000, 900)],
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripRouteOverview', () => {
|
||||
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
|
||||
const { result } = render(false)
|
||||
|
||||
expect(result.current.days).toEqual([])
|
||||
expect(result.current.totalDistance).toBe(0)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-002: sums every day into the trip total', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days).toHaveLength(2)
|
||||
expect(result.current.days.map(d => d.distance)).toEqual([12000, 12000])
|
||||
expect(result.current.totalDistance).toBe(24000)
|
||||
expect(result.current.totalDuration).toBe(1800)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const colors = result.current.days.map(d => d.color)
|
||||
// The same palette either way, so a day does not change colour when the trip is
|
||||
// read as a road trip instead of an overview.
|
||||
expect(colors[0]).toEqual(dayColor(1))
|
||||
expect(colors[1]).toEqual(dayColor(2))
|
||||
// Every drawn line is labelled with the colour of the day it belongs to.
|
||||
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
|
||||
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
|
||||
const { result } = render(true, {
|
||||
'1': [at(48.86, 2.35, 0, 1, { leg_transport_mode: 'walking' }), at(48.88, 2.36, 1, 1)],
|
||||
'2': ASSIGNMENTS['2'],
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].modes).toEqual(['walking'])
|
||||
expect(result.current.days[1].modes).toEqual(['driving'])
|
||||
expect(result.current.days[0].segments[0].mode).toBe('walking')
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-005: draws straight lines first and keeps them when routing refuses', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429'))
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
// The shape of the trip is still on the map...
|
||||
expect(result.current.lines).toHaveLength(2)
|
||||
expect(result.current.lines[0]).toEqual([[48.86, 2.35], [48.88, 2.36]])
|
||||
// ...but a leg that never answered contributes no distance.
|
||||
expect(result.current.totalDistance).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-006: a day with nothing to drive is left out', async () => {
|
||||
const { result } = render(true, { '1': ASSIGNMENTS['1'], '2': [at(45.76, 4.83, 0, 2)] })
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days.map(d => d.dayId)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-007: frames the whole trip, not one day of it', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.focusPoints.length).toBe(result.current.lines.flat().length)
|
||||
expect(result.current.focusPoints.length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-008: renaming a place does not re-route the trip', async () => {
|
||||
const { result, rerender } = render()
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const calls = vi.mocked(calculateRouteWithLegs).mock.calls.length
|
||||
|
||||
expect(calls).toBeGreaterThan(0)
|
||||
|
||||
// A new object identity with the same geometry: the plan memo re-runs, the
|
||||
// routing round must not.
|
||||
rerender({
|
||||
assignments: {
|
||||
...ASSIGNMENTS,
|
||||
'1': ASSIGNMENTS['1'].map(a => ({ ...a, place: { ...a.place, name: 'Renamed' } })),
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
/** One travel day of the overview: the roads it covers, in its own colour. */
|
||||
export interface TripOverviewDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** The core and casing this day is drawn in — the road trip's palette, so a day is
|
||||
* the same colour whichever way the trip is being read. */
|
||||
color: { line: string; casing: string }
|
||||
/** One polyline per run of the day, `[lat, lng]`. */
|
||||
lines: [number, number][][]
|
||||
segments: RouteSegment[]
|
||||
/** Metres and seconds, summed over the day's legs. */
|
||||
distance: number
|
||||
duration: number
|
||||
/** The modes this day is actually travelled in, first use first. */
|
||||
modes: string[]
|
||||
}
|
||||
|
||||
export interface TripRouteOverview {
|
||||
days: TripOverviewDay[]
|
||||
/** Every day's polylines flattened in trip order — what the map draws. */
|
||||
lines: [number, number][][]
|
||||
/** The colour of each entry of `lines`, same index. */
|
||||
lineColors: { line: string; casing: string }[]
|
||||
segments: RouteSegment[]
|
||||
/** Every drawn coordinate, so the map can frame the whole trip at once. */
|
||||
focusPoints: [number, number][]
|
||||
totalDistance: number
|
||||
totalDuration: number
|
||||
/** True until every leg has answered — the totals are a partial sum until then. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const EMPTY: TripRouteOverview = {
|
||||
days: [], lines: [], lineColors: [], segments: [], focusPoints: [],
|
||||
totalDistance: 0, totalDuration: 0, loading: false,
|
||||
}
|
||||
|
||||
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
|
||||
* exactly as the single-day route builds them. */
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string }
|
||||
|
||||
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
let i = 0
|
||||
while (i < run.length - 1) {
|
||||
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
|
||||
let end = i + 1
|
||||
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
|
||||
chunks.push({ points: run.slice(i, end + 1), mode })
|
||||
i = end
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
|
||||
|
||||
/**
|
||||
* Every travel day's route at once, each day in its own colour, with the trip's total
|
||||
* distance (#1736).
|
||||
*
|
||||
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
|
||||
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
|
||||
* you are looking at it alone or at the whole trip. Only the aggregation is new.
|
||||
*
|
||||
* Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and
|
||||
* the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the
|
||||
* day route and the sidebar connectors, so the day already on screen costs nothing.
|
||||
*/
|
||||
export function useTripRouteOverview(
|
||||
tripId: number | null,
|
||||
days: Day[],
|
||||
assignments: AssignmentsMap,
|
||||
reservations: Reservation[],
|
||||
accommodations: Accommodation[],
|
||||
profile: RouteProfileKey,
|
||||
enabled: boolean,
|
||||
): TripRouteOverview {
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const plan = useMemo(() => {
|
||||
if (!enabled) return []
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(day => ({
|
||||
day,
|
||||
runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }),
|
||||
}))
|
||||
.filter(entry => entry.runs.length > 0)
|
||||
}, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation])
|
||||
|
||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||
// place or editing its notes must not fire a routing round.
|
||||
const planKey = useMemo(
|
||||
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
|
||||
.map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|'))
|
||||
.join('/')}`).join(';'),
|
||||
[plan],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort()
|
||||
if (!plan.length) { setResult(EMPTY); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
|
||||
|
||||
// Chunk every day up front, so the routed answers can be filed by position and
|
||||
// reassembled in order however they come back.
|
||||
const chunked = plan.map(({ day, runs }) => {
|
||||
const dayDefaultMode = day.default_transport_mode || profile
|
||||
return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) }
|
||||
})
|
||||
|
||||
const assemble = (
|
||||
routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][],
|
||||
): TripRouteOverview => {
|
||||
const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => {
|
||||
const lines: [number, number][][] = []
|
||||
const segments: RouteSegment[] = []
|
||||
runs.forEach((chunks, r) => {
|
||||
const polyline: [number, number][] = []
|
||||
chunks.forEach((chunk, c) => {
|
||||
const answer = routed[d][r][c]
|
||||
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
|
||||
for (const point of coords) {
|
||||
// Drop the point shared with the previous chunk so concatenated legs
|
||||
// don't leave a duplicate at each junction.
|
||||
const last = polyline[polyline.length - 1]
|
||||
if (last && last[0] === point[0] && last[1] === point[1]) continue
|
||||
polyline.push(point)
|
||||
}
|
||||
if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode })
|
||||
})
|
||||
if (polyline.length >= 2) lines.push(polyline)
|
||||
})
|
||||
const modes: string[] = []
|
||||
for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode)
|
||||
return {
|
||||
dayId: day.id,
|
||||
dayNumber: day.day_number ?? 0,
|
||||
date: day.date ?? null,
|
||||
title: day.title ?? null,
|
||||
color: colorFor(day),
|
||||
lines,
|
||||
segments,
|
||||
distance: segments.reduce((sum, s) => sum + s.distance, 0),
|
||||
duration: segments.reduce((sum, s) => sum + s.duration, 0),
|
||||
modes,
|
||||
}
|
||||
})
|
||||
const lines = overviewDays.flatMap(d => d.lines)
|
||||
return {
|
||||
days: overviewDays,
|
||||
lines,
|
||||
lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)),
|
||||
segments: overviewDays.flatMap(d => d.segments),
|
||||
focusPoints: lines.flat(),
|
||||
totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0),
|
||||
totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0),
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as
|
||||
({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)))
|
||||
|
||||
// Straight lines first so the shape of the trip is on screen immediately, then the
|
||||
// real roads replace them — the same two-step the single-day route draws with.
|
||||
setResult({ ...assemble(routed), loading: true })
|
||||
|
||||
;(async () => {
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
chunked.forEach(({ day, runs }, d) => {
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
|
||||
} catch {
|
||||
// Routing refused this leg (usually a rate limit) — it keeps its
|
||||
// straight line and contributes no distance, exactly as a day route's
|
||||
// failed leg does.
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Small pool on purpose, the same one the sidebar connectors use: a fortnight is
|
||||
// dozens of legs and the routing host is often a shared OSRM.
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < tasks.length && !controller.signal.aborted) await tasks[next++]()
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
|
||||
|
||||
if (!controller.signal.aborted) setResult(assemble(routed))
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
// planKey is derived from the same inputs as plan, so keying on the string is
|
||||
// equivalent while staying stable across unrelated renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planKey, profile, tripId, distanceUnit])
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
/**
|
||||
* A real polyline6, encoded from three known points and verified by decoding it back.
|
||||
* Precision matters here more than anywhere else in the file: read as polyline5 the
|
||||
* first vertex would be 535.511, which is not a latitude, and a wrong guess draws a
|
||||
* line into the sea rather than failing.
|
||||
*/
|
||||
const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE'
|
||||
const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]]
|
||||
|
||||
const answer = (summary: Record<string, unknown> = {}) => ({
|
||||
trip: {
|
||||
legs: [{ shape: SHAPE }],
|
||||
summary: {
|
||||
has_time_restrictions: false,
|
||||
has_toll: false,
|
||||
has_highway: true,
|
||||
has_ferry: false,
|
||||
// Asked in kilometres, so this is 295.7 km and 191 minutes — the real
|
||||
// Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds.
|
||||
length: 295.7,
|
||||
time: 11460,
|
||||
...summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const setSettings = (patch: Record<string, string>) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
|
||||
|
||||
afterEach(() => {
|
||||
setSettings({ routing_base_url: '', valhalla_base_url: '' })
|
||||
})
|
||||
|
||||
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
|
||||
const BERLIN = { lat: 52.52, lng: 13.405 }
|
||||
|
||||
describe('valhallaBase', () => {
|
||||
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
expect(valhallaAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
|
||||
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
|
||||
expect(valhallaBase()).toBe('https://valhalla.example.org')
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => {
|
||||
// An operator who filled in their own router chose against the public hosts, usually
|
||||
// because the instance is not supposed to reach third parties at all. Adding one they
|
||||
// never asked for would undo that, and their MLD-built OSRM answers exclude itself.
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
expect(valhallaBase()).toBeNull()
|
||||
expect(valhallaAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => {
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' })
|
||||
expect(valhallaBase()).toBe('https://v.example.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runFrom', () => {
|
||||
const total = (data: unknown) => runFrom(data)?.total ?? null
|
||||
|
||||
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
|
||||
const leg = total(answer())
|
||||
expect(leg).not.toBeNull()
|
||||
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg!.duration).toBe(11460)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
|
||||
const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false }))
|
||||
expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => {
|
||||
// The public instance omits a flag it has nothing to say about, and the caller turns
|
||||
// this into a label. Undefined has to become false, not a truthy object.
|
||||
const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } })
|
||||
expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => {
|
||||
// What a request with a via really answers: one summary per pair of stops. The
|
||||
// schedule is built from those, so flattening them into a total would lose the
|
||||
// arrival time at every stop but the last.
|
||||
const chain = {
|
||||
trip: {
|
||||
legs: [
|
||||
{ shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } },
|
||||
{ shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } },
|
||||
],
|
||||
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
|
||||
},
|
||||
}
|
||||
const run = runFrom(chain)!
|
||||
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
|
||||
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
|
||||
expect(run.total.distance).toBe(150000)
|
||||
expect(run.total.duration).toBe(5400)
|
||||
// One tolled leg makes the drive tolled. `every` here would call a day with a
|
||||
// single toll booth in it toll-free.
|
||||
expect(run.total.hasToll).toBe(true)
|
||||
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
|
||||
// There is no honest way to divide one total between them, and a guess would reach
|
||||
// the user as a wrong arrival time rather than as a missing one.
|
||||
const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } }
|
||||
expect(runFrom(two)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-005: an error body is not a leg', () => {
|
||||
// What a 400 carries: no trip at all. Parsing it as one would produce a route of
|
||||
// NaN kilometres rather than the null every caller reads as "no second way".
|
||||
expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
|
||||
expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull()
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull()
|
||||
expect(total(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => {
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legAvoids', () => {
|
||||
const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({
|
||||
coordinates: SHAPE_POINTS, distance: 1, duration: 1,
|
||||
hasToll: false, hasHighway: false, hasFerry: false, ...flags,
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-001: reads the answer, not the request', () => {
|
||||
// use_tolls: 0 is a weighting. Between two points with no untolled connection the
|
||||
// answer is still a tolled road, honestly flagged, and labelling that "No tolls"
|
||||
// would put a lie on the map.
|
||||
expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-002: each class reads its own flag', () => {
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false)
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false)
|
||||
expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRouteAvoiding', () => {
|
||||
it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(body).toMatchObject({
|
||||
costing: 'auto',
|
||||
costing_options: { auto: { use_tolls: 0 } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
})
|
||||
// The radius is what makes a place in a pedestrian zone routable at all: without it
|
||||
// Vienna's Stephansdom answers error 442 and the whole leg is lost.
|
||||
expect(body!.locations).toEqual([
|
||||
{ lat: 53.5511, lon: 9.9937, radius: 50 },
|
||||
{ lat: 52.52, lon: 13.405, radius: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-002: each class maps to its own costing option', async () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
seen.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
|
||||
expect(seen.map(b => b.costing_options)).toEqual([
|
||||
{ auto: { use_highways: 0 } },
|
||||
{ auto: { use_ferry: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing: string }
|
||||
seen.push(body.costing)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')
|
||||
expect(seen).toEqual(['pedestrian', 'bicycle'])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => {
|
||||
// Every failure means the same thing to the caller — there is no second way to
|
||||
// show — so a 400 for an unroutable point and a 429 for the rate limit both end
|
||||
// here rather than as an error over a map that is drawing fine.
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, () =>
|
||||
HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })
|
||||
)
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => {
|
||||
let calls = 0
|
||||
setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' })
|
||||
server.use(
|
||||
http.post('https://not-valhalla.example.org/route', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
// A 404 is about the URL rather than about this leg, so remembering it saves every
|
||||
// later leg a request against a host that will never answer.
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => {
|
||||
let calls = 0
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) }))
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRun over a whole day', () => {
|
||||
/** A chain answer with one summary per leg, which is what a real request returns. */
|
||||
const chain = (legCount: number) => ({
|
||||
trip: {
|
||||
legs: Array.from({ length: legCount }, () => ({
|
||||
shape: SHAPE,
|
||||
summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false },
|
||||
})),
|
||||
summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const stops = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
|
||||
|
||||
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
calls++
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(6), 'driving', [])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(run!.legs).toHaveLength(5)
|
||||
expect(run!.total.distance).toBe(50000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
|
||||
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
|
||||
// point, which is exact rather than approximate — measured over a six-stop day,
|
||||
// [0..3] plus [3..5] came back within two metres of the same day asked whole.
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
// Fourteen stops: the first run takes ten, the second starts on the tenth again and
|
||||
// takes the remaining five. Nine pairs plus four.
|
||||
expect(sizes).toEqual([10, 5])
|
||||
// Thirteen pairs across two requests, none lost and none counted twice.
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
expect(run!.total.distance).toBe(130000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await valhallaRun(stops(28), 'driving', [])
|
||||
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(10)
|
||||
expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => {
|
||||
// Half a day of legs is worse than none. The schedule chains leg times blindly and
|
||||
// cannot tell that some are missing, so it would print a complete, plausible,
|
||||
// silently wrong timetable instead of leaving a visible gap.
|
||||
//
|
||||
// The SECOND CHUNK fails, not the second request: one attempt is retried, so failing
|
||||
// by call number would just be answered on the retry.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
if (body.locations.length < 10) {
|
||||
return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 })
|
||||
}
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => {
|
||||
// The caller retries the whole task, so without this a rate limit on the last piece
|
||||
// of a split day throws away the pieces that already answered and asks for all of
|
||||
// them again — three times over, against a host that allows one request a second.
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
calls++
|
||||
if (calls === 2) return new HttpResponse(null, { status: 429 })
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
// First piece, second piece refused, second piece again.
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
|
||||
await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => {
|
||||
// A day whose legs do not match its stops cannot be assigned to the rail, and
|
||||
// guessing which stop is missing would put the wrong time against the right place.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2))))
|
||||
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { decodePolyline } from './transitGeometry'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* Valhalla, the second routing engine, asked only the questions OSRM cannot answer.
|
||||
*
|
||||
* The shipped OSRM hosts are built without excludable road classes: their car profile
|
||||
* says so in its own Lua ("no memory for classes on demand server"), and `exclude=`
|
||||
* comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking
|
||||
* for — drive this without the tolls — has never worked on a default install.
|
||||
*
|
||||
* FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there
|
||||
* avoidance is a runtime weighting rather than a profile that has to be rebuilt. This
|
||||
* module is the adapter: same question, different engine, answer shaped like the OSRM
|
||||
* one so the callers do not have to care which engine spoke.
|
||||
*
|
||||
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
|
||||
* - it caps at ten locations, and following a track sends a day's stops plus up to
|
||||
* nine anchors, so the bundled requests would start failing;
|
||||
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
|
||||
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
|
||||
* until a search radius is allowed;
|
||||
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
|
||||
* above OSRM's on average and 36 % above on one, and those times feed the arrival
|
||||
* clocks of a road trip day. Mixing engines inside one chain would move times
|
||||
* nobody asked to move.
|
||||
*
|
||||
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
|
||||
* on demand, drawn as an offer next to the route rather than replacing it.
|
||||
*/
|
||||
|
||||
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
|
||||
|
||||
/**
|
||||
* How far Valhalla may look for a road under a point, in metres.
|
||||
*
|
||||
* Zero is its default and it is the wrong default for TREK's data, which is full of
|
||||
* museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on
|
||||
* Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it
|
||||
* routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it.
|
||||
*/
|
||||
const SNAP_RADIUS_M = 50
|
||||
|
||||
/** TREK's profile names against Valhalla's costing models. */
|
||||
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'auto',
|
||||
walking: 'pedestrian',
|
||||
cycling: 'bicycle',
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller can ask to be left out, against the costing option that asks for it.
|
||||
*
|
||||
* Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason
|
||||
* this engine is worth having. Asked to avoid tolls between New York and Boston it
|
||||
* still returns a tolled road, because there is no untolled way, where OSRM's `exclude`
|
||||
* would refuse to answer at all. The caller has to read the answer to find out what it
|
||||
* got, which is what `hasToll` and friends are for.
|
||||
*/
|
||||
const AVOID_OPTION: Record<AvoidClass, string> = {
|
||||
motorway: 'use_highways',
|
||||
toll: 'use_tolls',
|
||||
ferry: 'use_ferry',
|
||||
}
|
||||
|
||||
/** The same three classes the rest of the app names; aliased so this file reads on its own. */
|
||||
export type AvoidClass = RouteAvoidClass
|
||||
|
||||
/**
|
||||
* Locations one request may carry. The eleventh answers
|
||||
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
|
||||
*/
|
||||
const MAX_LOCATIONS = 10
|
||||
|
||||
/**
|
||||
* Pause between the pieces of a split day, in milliseconds.
|
||||
*
|
||||
* The public instance allows about one request a second per client, and a day long
|
||||
* enough to be split is exactly the case that would fire two back to back. Matches the
|
||||
* 1100 ms the road trip rail already leaves between its own days.
|
||||
*/
|
||||
const CHUNK_PAUSE_MS = 1100
|
||||
|
||||
/**
|
||||
* Extra attempts for one piece of a split day.
|
||||
*
|
||||
* One, not a ladder: the common failure here is the public instance's rate limit, which
|
||||
* a single pause clears, and anything a second attempt cannot fix is not going to be
|
||||
* fixed by a fourth while somebody waits for their map.
|
||||
*/
|
||||
const CHUNK_RETRIES = 1
|
||||
|
||||
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
|
||||
export interface ValhallaLeg {
|
||||
coordinates: [number, number][]
|
||||
/** Metres. Valhalla is asked in kilometres and converted here. */
|
||||
distance: number
|
||||
/** Seconds. */
|
||||
duration: number
|
||||
/** What the route ACTUALLY has, as opposed to what was asked for. */
|
||||
hasToll: boolean
|
||||
hasHighway: boolean
|
||||
hasFerry: boolean
|
||||
}
|
||||
|
||||
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
|
||||
export interface ValhallaRun {
|
||||
legs: ValhallaLeg[]
|
||||
total: ValhallaLeg
|
||||
/**
|
||||
* Where each waypoint actually met the road.
|
||||
*
|
||||
* Reconstructed from the shape rather than read from a field, because Valhalla echoes
|
||||
* the coordinates it was GIVEN in `trip.locations` and never says where it bound them.
|
||||
* The ends of a leg's line are those points: measured against Füssen, its first vertex
|
||||
* came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same
|
||||
* request to the digit. Without this the dashed kerb-to-door spur from #2273 would
|
||||
* quietly disappear from every day routed by this engine.
|
||||
*/
|
||||
snapped: SnappedWaypoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance's Valhalla, or null when it should not ask one.
|
||||
*
|
||||
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
|
||||
* with one condition. An operator who filled in their OWN router chose deliberately
|
||||
* against the public hosts, usually because their instance is not supposed to talk to
|
||||
* third parties at all, and quietly adding a second public host they never asked for
|
||||
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
|
||||
* pipeline answers `exclude` on its own, which is exactly the fallback below.
|
||||
*/
|
||||
export function valhallaBase(): string | null {
|
||||
const settings = useSettingsStore.getState().settings
|
||||
const configured = settings.valhalla_base_url?.trim()
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
|
||||
}
|
||||
|
||||
/** True when this engine can be asked at all, so callers can skip building a request. */
|
||||
export function valhallaAvailable(): boolean {
|
||||
return valhallaBase() !== null
|
||||
}
|
||||
|
||||
/** Hosts that have already proved they are not a Valhalla, by base URL. */
|
||||
const notValhalla = new Set<string>()
|
||||
|
||||
/**
|
||||
* One leg with a road class weighted away, or null when it cannot be had.
|
||||
*
|
||||
* Null for every failure, deliberately: this is an offer next to a route that already
|
||||
* exists, so a host that is down, a rate limit, a point in a field and a genuinely
|
||||
* connected-only-by-motorway leg all mean the same thing to the caller — there is no
|
||||
* second way to show. The one failure worth remembering is a host that is not a
|
||||
* Valhalla at all, because that is true of every leg from now on rather than this one.
|
||||
*/
|
||||
export async function valhallaRouteAvoiding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaLeg | null> {
|
||||
const run = await valhallaRun([from, to], profile, [avoid], signal)
|
||||
return run ? run.total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain of waypoints, one leg per pair, or null when it cannot be had.
|
||||
*
|
||||
* Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled
|
||||
* into one request precisely so it appears at once rather than trickling in — so a day
|
||||
* over the cap is cut into runs that overlap by one point and the legs are concatenated.
|
||||
* That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5]
|
||||
* came back within two metres and under a second of the same day asked whole, because
|
||||
* the shared point is routed to and from identically.
|
||||
*
|
||||
* The pieces go out one after another with a pause, since a day long enough to need
|
||||
* splitting is exactly the one that would otherwise fire two requests inside a second
|
||||
* at a host that allows one.
|
||||
*/
|
||||
export async function valhallaRun(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
const chunks = chunkWaypoints(waypoints)
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (signal?.aborted) return null
|
||||
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
|
||||
// Retried per piece rather than per day. The caller retries the whole task, so a
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
let run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
// Not when the host just proved it is not a Valhalla at all: that answer will not
|
||||
// change on a second ask, and asking anyway is the request the memo exists to save.
|
||||
if (notValhalla.has(base)) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
}
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
if (!run) return null
|
||||
legs.push(...run.legs)
|
||||
}
|
||||
if (legs.length !== waypoints.length - 1) return null
|
||||
return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each waypoint met the road, taken off the ends of the legs that touch it.
|
||||
*
|
||||
* A leg's line starts at the bound position of its first stop and ends at the bound
|
||||
* position of its second, so stop i is the head of leg i, and the last stop is the tail
|
||||
* of the last leg. The distance is measured here rather than reported, the same recovery
|
||||
* `readSnapped` already applies when OSRM leaves the field out.
|
||||
*/
|
||||
function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] {
|
||||
return waypoints.map((waypoint, i) => {
|
||||
const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates
|
||||
const at = i < legs.length ? line[0] : line[line.length - 1]
|
||||
const asked: [number, number] = [waypoint.lat, waypoint.lng]
|
||||
return {
|
||||
asked,
|
||||
at,
|
||||
meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs of at most ten points, each starting on the one the previous ended at. */
|
||||
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
|
||||
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
|
||||
const chunks: Waypoint[][] = []
|
||||
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
|
||||
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = {}
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
const body = {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
costing,
|
||||
costing_options: { [costing]: options },
|
||||
directions_options: { units: 'kilometers' },
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/route`, {
|
||||
method: 'POST',
|
||||
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
|
||||
// client to identify itself with. It costs a preflight, which is answered.
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
|
||||
// or a proxy that swallowed the path. Every other status is about this request:
|
||||
// 400 carries the real refusals (unroutable point, over 1500 km apart) and 429
|
||||
// is the public instance's one-per-second.
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
return null
|
||||
}
|
||||
return runFrom(await response.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several legs read as one, the way a caller that only wants the whole drive sees it.
|
||||
*
|
||||
* The seam matters more than it looks. Every leg ends where the next begins, so a plain
|
||||
* concatenation repeats that vertex — and this line is not only drawn: a via dropped on
|
||||
* the route is projected onto it to work out which leg it belongs after, and that index
|
||||
* is written to the database. A repeated point can put the via on the wrong leg, which
|
||||
* is a stored wrong answer rather than an invisible one.
|
||||
*/
|
||||
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
|
||||
const coordinates: [number, number][] = []
|
||||
for (const leg of legs) {
|
||||
const last = coordinates[coordinates.length - 1]
|
||||
const first = leg.coordinates[0]
|
||||
const continues = last && first && last[0] === first[0] && last[1] === first[1]
|
||||
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
|
||||
}
|
||||
return {
|
||||
coordinates,
|
||||
distance: legs.reduce((sum, l) => sum + l.distance, 0),
|
||||
duration: legs.reduce((sum, l) => sum + l.duration, 0),
|
||||
// One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day
|
||||
// with a single toll booth in it would be labelled toll-free.
|
||||
hasToll: legs.some(l => l.hasToll),
|
||||
hasHighway: legs.some(l => l.hasHighway),
|
||||
hasFerry: legs.some(l => l.hasFerry),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Valhalla answer as a leg, or null when it is not one.
|
||||
*
|
||||
* Split out because it is the whole of the format knowledge and the only part worth
|
||||
* testing without a network: the native shape, not `format=osrm`. That flag exists and
|
||||
* would have kept the response side unchanged, but it drops `has_toll` — the one field
|
||||
* this is all for — and carries every intersection instead, which measured 5.7 times
|
||||
* larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one.
|
||||
*/
|
||||
/**
|
||||
* The answer read leg by leg, which is what a whole day needs.
|
||||
*
|
||||
* Valhalla gives every leg its own summary, so a chain comes back already divided the
|
||||
* way a road trip day wants it: this stop to the next, with its own distance, its own
|
||||
* drive time, and its own honest `has_toll`. Reading only the trip summary would flatten
|
||||
* a day into one number and lose the per-leg times the schedule is built from.
|
||||
*
|
||||
* The fallback to the trip summary is for a single leg only. Where several legs carry no
|
||||
* summary of their own there is no honest way to divide one total between them, and a
|
||||
* guess here would arrive as a wrong arrival time rather than as a missing one.
|
||||
*/
|
||||
export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
|
||||
const trip = tripOf(data)
|
||||
if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
for (const leg of trip.legs) {
|
||||
if (typeof leg.shape !== 'string') return null
|
||||
const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined)
|
||||
if (!summary) return null
|
||||
// Precision 6, verified against a known point: decoded as 5 the first vertex of a
|
||||
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
|
||||
const measure = measured(summary, decodePolyline(leg.shape, 6))
|
||||
if (!measure) return null
|
||||
legs.push(measure)
|
||||
}
|
||||
return { legs, total: joinLegs(legs) }
|
||||
}
|
||||
|
||||
interface ValhallaSummary { [key: string]: number | boolean | undefined }
|
||||
|
||||
function tripOf(data: unknown) {
|
||||
return (data as {
|
||||
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
|
||||
})?.trip
|
||||
}
|
||||
|
||||
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
|
||||
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
|
||||
if (!coordinates.length) return null
|
||||
const length = Number(summary.length)
|
||||
const time = Number(summary.time)
|
||||
if (!Number.isFinite(length) || !Number.isFinite(time)) return null
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
// Asked in kilometres, answered in kilometres, handed on in metres because that is
|
||||
// what every caller already reads off an OSRM route.
|
||||
distance: length * 1000,
|
||||
duration: time,
|
||||
hasToll: summary.has_toll === true,
|
||||
hasHighway: summary.has_highway === true,
|
||||
hasFerry: summary.has_ferry === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route actually avoids what it was asked to avoid.
|
||||
*
|
||||
* The reason this function exists rather than a boolean on the request: `use_tolls: 0`
|
||||
* is a weighting, so between two points with no untolled connection Valhalla answers a
|
||||
* tolled route and reports it honestly. Labelling that "No tolls" would be a lie on
|
||||
* screen, and the flags are in the answer precisely so nobody has to guess.
|
||||
*/
|
||||
export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean {
|
||||
if (avoid === 'toll') return !leg.hasToll
|
||||
if (avoid === 'motorway') return !leg.hasHighway
|
||||
return !leg.hasFerry
|
||||
}
|
||||
@@ -95,6 +95,8 @@ interface DayPlanSidebarProps {
|
||||
onSetRouteProfile?: (profile: string) => void
|
||||
onAddPlace?: () => void
|
||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||
/** Open the place form already pointed at this day, to create a new place there. */
|
||||
onCreatePlaceForDay?: (dayId: number) => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
@@ -146,6 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown = false,
|
||||
routeProfile = 'driving',
|
||||
@@ -1073,6 +1076,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1244,6 +1248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1892,7 +1897,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
{/* An empty day is where somebody wants to add something, so
|
||||
the slot offers to do it instead of only stating the fact.
|
||||
Without the handler (no edit rights) it stays the sentence. */}
|
||||
{onCreatePlaceForDay ? (
|
||||
<button type="button"
|
||||
onClick={e => { e.stopPropagation(); onCreatePlaceForDay(day.id) }}
|
||||
className="text-content-muted"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 12px', borderRadius: 8,
|
||||
// A solid outline and a whisper of tint rather than the
|
||||
// dashed grey placeholder it replaced: it is the one thing
|
||||
// to do on an empty day, so it should read as an offer
|
||||
// without competing with the day rows around it.
|
||||
background: 'color-mix(in srgb, var(--accent) 4%, transparent)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 10%, transparent)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 4%, transparent)' }}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} /> {t('dayplan.addPlaceHere')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { RouteSegment } from '../../types'
|
||||
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
|
||||
// its legs carry the profile-true durationText anyway, the icon just signals that
|
||||
// the time came from a plugin router (e.g. EV routing with charge time folded in).
|
||||
function profileIcon(profile: string) {
|
||||
export function profileIcon(profile: string) {
|
||||
if (profile === 'walking') return Footprints
|
||||
if (profile.startsWith('plugin:')) return Zap
|
||||
return Car
|
||||
|
||||
@@ -467,41 +467,33 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
})
|
||||
})
|
||||
/**
|
||||
* FE-PDC-027..029 — the nudge towards a Google key.
|
||||
* FE-PDC-027 — nothing came back for this place.
|
||||
*
|
||||
* Deliberately narrow: it appears only when the free sources came up empty AND
|
||||
* no key is configured. On an instance with a key there is nothing to suggest,
|
||||
* and on a place the free sources described it would read as an advert.
|
||||
* It used to be a line of grey text plus, when no Google key was set, a panel
|
||||
* telling the reader to go and ask an administrator for one. The mascot says the
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — no Google key', () => {
|
||||
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
renderColumn()
|
||||
|
||||
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
|
||||
empty()
|
||||
renderColumn({ hasMapsKey: false })
|
||||
|
||||
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
|
||||
empty()
|
||||
renderColumn({ hasMapsKey: true })
|
||||
|
||||
await screen.findByText('places.details.nothing')
|
||||
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-029: stays quiet when the free sources did find something', async () => {
|
||||
it('FE-PDC-028: stays quiet when the free sources did find something', async () => {
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
|
||||
})
|
||||
renderColumn({ hasMapsKey: false })
|
||||
renderColumn()
|
||||
|
||||
await screen.findByText('Ein Museum.')
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ImageOff,
|
||||
Landmark,
|
||||
Leaf,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Sprout,
|
||||
Star,
|
||||
Sun,
|
||||
@@ -27,6 +25,7 @@ import { mapsApi } from '../../api/client'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/** The place the column is describing. Null while nothing is selected. */
|
||||
@@ -53,7 +52,6 @@ interface PlaceDetailsColumnProps {
|
||||
/** For grouping the rating count's digits. */
|
||||
locale?: string
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
hasMapsKey?: boolean
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -119,7 +117,6 @@ export default function PlaceDetailsColumn({
|
||||
language,
|
||||
timeFormat = '24h',
|
||||
locale = 'en-US',
|
||||
hasMapsKey = false,
|
||||
t,
|
||||
}: PlaceDetailsColumnProps): React.ReactElement {
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
@@ -208,7 +205,17 @@ export default function PlaceDetailsColumn({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5">
|
||||
{!selection && <p className="text-caption text-content-muted">{t('places.details.empty')}</p>}
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. */}
|
||||
{!selection && (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection && state === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-caption text-content-muted">
|
||||
@@ -223,17 +230,20 @@ export default function PlaceDetailsColumn({
|
||||
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
|
||||
)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-caption text-content-muted">
|
||||
<ImageOff className="w-4 h-4 shrink-0" />
|
||||
{t('places.details.nothing')}
|
||||
</div>
|
||||
{/* Only when both are true. With a key configured there is nothing
|
||||
to suggest, and on a place the free sources DID describe the
|
||||
suggestion would be an advert. */}
|
||||
{!hasMapsKey && <NoKeyHint t={t} />}
|
||||
</div>
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -462,19 +472,6 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
|
||||
* It also names who to ask: the key is an instance-wide setting, so on most
|
||||
* installs the person reading this cannot act on it themselves.
|
||||
*/
|
||||
function NoKeyHint({ t }: { t: TranslationFn }): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
</span>
|
||||
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t('places.details.noKeyHint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The star rating, as stars.
|
||||
|
||||
@@ -299,6 +299,43 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => {
|
||||
// The layer's second line is the name written on the building, not an
|
||||
// address. Joining the two and searching for it asks a question nobody
|
||||
// typed, and whatever came back first was taken as the place the user had
|
||||
// already picked. The row carries coordinates, so the fallback uses those.
|
||||
const user = userEvent.setup();
|
||||
let searched = 0;
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{
|
||||
placeId: 'node:9712313',
|
||||
mainText: 'Tokio Hauptbahnhof',
|
||||
secondaryText: '東京駅丸の内駅舎',
|
||||
source: 'openstreetmap',
|
||||
lat: 35.6811816,
|
||||
lng: 139.76598265,
|
||||
}],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () => {
|
||||
searched += 1;
|
||||
return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio');
|
||||
await user.click(await screen.findByText('Tokio Hauptbahnhof'));
|
||||
|
||||
expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument();
|
||||
expect(searched).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
@@ -349,10 +386,80 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||
// hasMapsKey is false by default in beforeEach
|
||||
it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => {
|
||||
// It used to say "Using OpenStreetMap" above the box whenever no Google key
|
||||
// was set, which was a sentence about configuration in the middle of a form
|
||||
// about a place. Each result names its own source now, which is the question
|
||||
// people actually had.
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByText(/OpenStreetMap/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
||||
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => {
|
||||
// The keystroke path asks both indexes at once, so the name the response
|
||||
// carries for the whole list ('trek-places') is true of the call and wrong
|
||||
// for half the rows. Marking every suggestion TREK is how a list that did
|
||||
// contain OpenStreetMap places read as if it never had.
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [
|
||||
{ placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' },
|
||||
{ placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' },
|
||||
],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station');
|
||||
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
|
||||
// The whole point of putting it on the row: with both indexes answering,
|
||||
// a label above the list could only name one of them. The index marks its
|
||||
// own rows, so the unmarked one is OpenStreetMap by elimination.
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [
|
||||
{ name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' },
|
||||
{ name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' },
|
||||
],
|
||||
source: 'trek-places+openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Category ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { STOP_KINDS } from '../Roadtrip/stopKinds'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -19,6 +18,7 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { sourceLabelFor } from '../../utils/placeSource'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
@@ -56,6 +56,32 @@ interface PlaceFormModalProps {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* One row of the typed-ahead list, as the server sends it.
|
||||
*
|
||||
* `source`, `lat` and `lng` are optional because not every index fills them:
|
||||
* Google answers with neither, and the mark falls back to the name the whole
|
||||
* list carries.
|
||||
*/
|
||||
type Suggestion = {
|
||||
placeId: string
|
||||
mainText: string
|
||||
secondaryText: string
|
||||
source?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
|
||||
function SourceBadge({ label }: { label: string | null }) {
|
||||
if (!label) return null
|
||||
return (
|
||||
<span className="shrink-0 rounded-md border border-edge bg-surface-secondary px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||
* render over the form fields. */
|
||||
@@ -92,10 +118,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
} = props
|
||||
// Hidden while the addon is off, because the kinds only mean anything to the road trip
|
||||
// rail: on an instance without it they would be six labels that change nothing.
|
||||
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
/** What answered the last full search. Only a fallback: a merged list carries the source per place. */
|
||||
const [searchSource, setSearchSource] = useState<string>('')
|
||||
/**
|
||||
* What produced the list currently on screen, kept for the shadow log: the
|
||||
* query as typed and the provider the envelope named. A ref rather than
|
||||
@@ -118,7 +145,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
const [acSuggestions, setAcSuggestions] = useState<Suggestion[]>([])
|
||||
// Which index answered the last keystroke, for the rows that do not say so
|
||||
// themselves. Google and the OpenStreetMap fallback each answer from one
|
||||
// place; the index path answers from two at once and marks every row.
|
||||
const [acSource, setAcSource] = useState<string>('')
|
||||
const [acHighlight, setAcHighlight] = useState(-1)
|
||||
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const acAbortRef = useRef<AbortController | null>(null)
|
||||
@@ -255,6 +286,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query, source: result.source || 'unknown' }
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcSource(result.source || '')
|
||||
setAcHighlight(-1)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
@@ -315,6 +347,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
setMapsResults(result.places || [])
|
||||
setSearchSource(result.source || '')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
@@ -371,7 +404,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
const handleSelectSuggestion = async (suggestion: Suggestion) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
@@ -399,6 +432,21 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
|
||||
// The layer's rows carry no address; their second line is the name
|
||||
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
|
||||
// is not a question anybody asked, and its first answer would be
|
||||
// whatever the index made of it — a different place, chosen silently.
|
||||
// The suggestion already knows where it is, so use that.
|
||||
place = {
|
||||
name: suggestion.mainText,
|
||||
address: '',
|
||||
lat: suggestion.lat,
|
||||
lng: suggestion.lng,
|
||||
osm_id: suggestion.placeId,
|
||||
source: 'openstreetmap',
|
||||
}
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language, locationBiasPoint)
|
||||
@@ -569,7 +617,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -589,6 +636,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
fileRef,
|
||||
acSuggestions,
|
||||
setAcSuggestions,
|
||||
acSource,
|
||||
searchSource,
|
||||
acHighlight,
|
||||
setAcHighlight,
|
||||
acDebounceRef,
|
||||
@@ -642,7 +691,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -662,6 +710,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
fileRef,
|
||||
acSuggestions,
|
||||
setAcSuggestions,
|
||||
acSource,
|
||||
searchSource,
|
||||
acHighlight,
|
||||
setAcHighlight,
|
||||
acDebounceRef,
|
||||
@@ -743,18 +793,12 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
language={language}
|
||||
timeFormat={S.timeFormat}
|
||||
locale={S.locale}
|
||||
hasMapsKey={S.hasMapsKey}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
|
||||
{/* Place Search */}
|
||||
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
|
||||
{!hasMapsKey && (
|
||||
<p className="mb-2 text-xs text-content-faint">
|
||||
{t('places.osmActive')}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -795,10 +839,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
idx === acHighlight ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{s.mainText}</div>
|
||||
{s.secondaryText && (
|
||||
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">{s.mainText}</div>
|
||||
{s.secondaryText && (
|
||||
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
|
||||
)}
|
||||
</div>
|
||||
<SourceBadge label={sourceLabelFor(s, acSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -815,8 +864,13 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
|
||||
>
|
||||
<div className="font-medium text-sm">{result.name}</div>
|
||||
<div className="text-xs text-content-muted truncate">{result.address}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">{result.name}</div>
|
||||
<div className="text-xs text-content-muted truncate">{result.address}</div>
|
||||
</div>
|
||||
<SourceBadge label={sourceLabelFor(result, searchSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -914,30 +968,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What kind of stop this is on a drive.
|
||||
Under the category rather than beside it, because the two answer different
|
||||
questions: a category is the traveller's own label, editable and shared across
|
||||
the whole instance, while this is a fact about the place. Refuelling is not a
|
||||
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
|
||||
is still a fuel stop when the mode is switched on later, and this is the only
|
||||
place in the web UI where the kind can be taken off again. */}
|
||||
{roadtripEnabled ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
|
||||
<CustomSelect
|
||||
value={form.stop_type ?? ''}
|
||||
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
|
||||
placeholder={t('roadtrip.stop.none')}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.stop.none') },
|
||||
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
|
||||
|
||||
@@ -88,6 +88,39 @@ describe('PlacesSidebar', () => {
|
||||
expect(onAddPlace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* The split add button (a day is open).
|
||||
*
|
||||
* Two buttons rather than one, because the pool sits a click away from the plan
|
||||
* and a place you already know belongs to today should not need a second trip
|
||||
* through the day picker. The second one stays mounted and collapsed so it has
|
||||
* something to animate out of.
|
||||
*/
|
||||
it('FE-COMP-PLACES-005b: with no day open there is one add button, at full length', () => {
|
||||
render(<PlacesSidebar {...defaultProps} selectedDayId={null} onAddPlaceToSelectedDay={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('New place')).not.toBeInTheDocument();
|
||||
// Present in the DOM so it can animate, but out of reach until a day is open.
|
||||
const quick = screen.getByTestId('add-place-to-day');
|
||||
expect(quick).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(quick).toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACES-005c: an open day splits the button and shortens the main label', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddPlaceToSelectedDay = vi.fn();
|
||||
render(<PlacesSidebar {...defaultProps} selectedDayId={7} onAddPlaceToSelectedDay={onAddPlaceToSelectedDay} />);
|
||||
|
||||
// Shortened so both fit side by side without either truncating.
|
||||
expect(screen.getByText('New place')).toBeInTheDocument();
|
||||
|
||||
const quick = screen.getByTestId('add-place-to-day');
|
||||
expect(quick).toHaveAttribute('tabindex', '0');
|
||||
await user.click(quick);
|
||||
expect(onAddPlaceToSelectedDay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACES-006: clicking a place calls onPlaceClick with place id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlaceClick = vi.fn();
|
||||
@@ -255,6 +288,113 @@ describe('Filter tabs', () => {
|
||||
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Unplanned Place')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── The open day narrows the pool, and says so ──────────────────────────────
|
||||
//
|
||||
// The map has followed the selected day on this filter since #2024 while the list
|
||||
// did not, so a trip with everything planned read "55" in the pool beside five pins
|
||||
// on the map. Reported twice in the same Discord thread as the map being broken.
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-052: with a day open, "Planned" shows that day, not the whole trip', () => {
|
||||
const today = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const assignments = {
|
||||
'1': [buildAssignment({ place: today, day_id: 1 })],
|
||||
'2': [buildAssignment({ place: otherDay, day_id: 2 })],
|
||||
};
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today, otherDay]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('On This Day')).toBeInTheDocument();
|
||||
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-053: without a day open it is the whole trip again', () => {
|
||||
const dayOne = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const dayTwo = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const assignments = {
|
||||
'1': [buildAssignment({ place: dayOne, day_id: 1 })],
|
||||
'2': [buildAssignment({ place: dayTwo, day_id: 2 })],
|
||||
};
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[dayOne, dayTwo]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('On This Day')).toBeInTheDocument();
|
||||
expect(screen.getByText('On Another Day')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-054: the note only appears while the day is actually narrowing', () => {
|
||||
const today = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const assignments = { '1': [buildAssignment({ place: today, day_id: 1 })] };
|
||||
const onClearSelectedDay = vi.fn();
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
const { rerender } = render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }] as never}
|
||||
selectedDayId={1}
|
||||
onClearSelectedDay={onClearSelectedDay}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Showing the open day only')).toBeInTheDocument();
|
||||
|
||||
// Dismissing it asks for the day to be closed rather than changing the filter.
|
||||
fireEvent.click(screen.getByLabelText('Show the whole trip'));
|
||||
expect(onClearSelectedDay).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No day open, nothing narrowed, no note.
|
||||
rerender(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }] as never}
|
||||
selectedDayId={null}
|
||||
onClearSelectedDay={onClearSelectedDay}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-055: "Unplanned" stays trip-wide while a day is open', () => {
|
||||
// A place assigned to some other day is planned, whichever day happens to be open,
|
||||
// so it must not reappear in the unplanned pool.
|
||||
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const loose = buildPlace({ id: 93, name: 'Not Planned At All' });
|
||||
const assignments = { '2': [buildAssignment({ place: otherDay, day_id: 2 })] };
|
||||
seedStore(useTripStore, { placesFilter: 'unplanned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[otherDay, loose]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Not Planned At All')).toBeInTheDocument();
|
||||
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,7 +43,19 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
<PlacesSelectionBar {...S} />
|
||||
) : (
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
{/* A badge across the whole rail rather than a line of text hugging the
|
||||
left edge: it reads as the list's header instead of as a stray label.
|
||||
Outlined rather than filled, because the tertiary surface is a slate
|
||||
tone and put a blue cast on the panel. */}
|
||||
<div className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '4px 10px', borderRadius: 99,
|
||||
background: 'transparent', border: '1px solid var(--border-faint)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
}}>
|
||||
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star } from 'lucide-react'
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
/**
|
||||
* Below this the two labels stop fitting side by side and both buttons fall back
|
||||
* to their icon. Measured on the button row itself rather than derived from the
|
||||
* sidebar width: the labels are translated, so how much room they need differs
|
||||
* per locale, and the row is what actually runs out of space. The rail is
|
||||
* draggable down to 200px, which leaves the row 168.
|
||||
*/
|
||||
const COMPACT_BUTTONS_WIDTH = 232
|
||||
|
||||
export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -21,52 +31,118 @@ export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
|
||||
export function PlacesHeader(S: SidebarState) {
|
||||
const {
|
||||
canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
|
||||
canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, plannedFilterIds, dayScoped, onClearSelectedDay, hasTracks,
|
||||
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
|
||||
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
|
||||
ratingFilter, setRatingFilter,
|
||||
starDropOpen, setStarDropOpen,
|
||||
} = S
|
||||
const dayOpen = selectedDayId != null
|
||||
const { ref: buttonRowRef, width: buttonRowWidth } = useElementSize<HTMLDivElement>()
|
||||
// Zero is the first paint, before the observer has measured anything — treat
|
||||
// that as roomy so the labels do not flash away and back on every mount.
|
||||
const compact = buttonRowWidth > 0 && buttonRowWidth < COMPACT_BUTTONS_WIDTH
|
||||
const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace')
|
||||
const fileImportLabel = t('places.importFile')
|
||||
const listImportLabel = t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
|
||||
{canEditPlaces && <button type="button"
|
||||
onClick={onAddPlace}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>}
|
||||
{canEditPlaces && <div ref={buttonRowRef} style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
{/* The label shortens while the second button is out, so both fit side by
|
||||
side in a default rail without either one truncating; a squeezed rail
|
||||
drops it entirely and the aria-label carries the name instead. */}
|
||||
<button type="button"
|
||||
onClick={onAddPlace}
|
||||
aria-label={compact ? addLabel : undefined}
|
||||
title={compact ? addLabel : undefined}
|
||||
className="bg-accent text-accent-text motion-reduce:transition-none"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
// Both halves are driven by flex-basis rather than by grow, so the
|
||||
// pair lands on an exact 50/50 and the handover animates in both
|
||||
// directions. Half of the 6px gap comes off each side.
|
||||
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '100%',
|
||||
minWidth: 0, padding: '8px 12px', borderRadius: 12, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
transition: 'flex-basis 180ms ease',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{!compact && (
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{addLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Kept mounted and collapsed rather than unmounted, so it has something
|
||||
to animate out of: a button that only exists while a day is open
|
||||
would appear and vanish on the spot. Hidden from the tab order and
|
||||
from screen readers in the same breath, because a zero-width button
|
||||
is still focusable otherwise. */}
|
||||
{onAddPlaceToSelectedDay && (
|
||||
<Tooltip label={t('places.addToSelectedDay')} disabled={!dayOpen}>
|
||||
<button type="button"
|
||||
onClick={onAddPlaceToSelectedDay}
|
||||
aria-label={t('places.addToSelectedDay')}
|
||||
data-testid="add-place-to-day"
|
||||
aria-hidden={!dayOpen}
|
||||
tabIndex={dayOpen ? 0 : -1}
|
||||
className="bg-accent text-accent-text motion-reduce:transition-none"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '0%',
|
||||
minWidth: 0,
|
||||
opacity: dayOpen ? 1 : 0,
|
||||
padding: dayOpen ? '8px 12px' : 0,
|
||||
borderRadius: 12, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
pointerEvents: dayOpen ? 'auto' : 'none',
|
||||
transition: 'flex-basis 180ms ease, opacity 140ms ease, padding 180ms ease',
|
||||
}}
|
||||
>
|
||||
<CalendarPlus size={14} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
{!compact && t('places.addToDayShort')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>}
|
||||
{canEditPlaces && <>
|
||||
{/* Same squeeze rule as the row above, measured off the same rail: once the
|
||||
add buttons lose their labels these two would not fit either. */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setFileImportOpen(true)}
|
||||
aria-label={compact ? fileImportLabel : undefined}
|
||||
title={compact ? fileImportLabel : undefined}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
<FileDown size={11} strokeWidth={2} /> {!compact && fileImportLabel}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setListImportOpen(true)}
|
||||
aria-label={compact ? listImportLabel : undefined}
|
||||
title={compact ? listImportLabel : undefined}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
<MapPin size={11} strokeWidth={2} /> {!compact && listImportLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
|
||||
@@ -87,7 +163,11 @@ export function PlacesHeader(S: SidebarState) {
|
||||
const counts = {
|
||||
all: baseFiltered.length,
|
||||
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
|
||||
planned: baseFiltered.filter(p => plannedIds.has(p.id)).length,
|
||||
// While a day is open this counts that day's plan, the same set the list and
|
||||
// the map show. Counting the whole trip here is what made the tab read 55
|
||||
// beside five pins, with nothing to say the two were answering different
|
||||
// questions.
|
||||
planned: baseFiltered.filter(p => plannedFilterIds.has(p.id)).length,
|
||||
tracks: baseFiltered.filter(p => p.route_geometry).length,
|
||||
}
|
||||
const tabs = ([
|
||||
@@ -97,7 +177,11 @@ export function PlacesHeader(S: SidebarState) {
|
||||
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
|
||||
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }>
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
// The group always spans the rail and never wraps onto a second line.
|
||||
// Each tab keeps its own text width and only the leftover space is
|
||||
// shared out, so "Unplanned" stays wider than "All" instead of the four
|
||||
// being forced to one size.
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'nowrap' }}>
|
||||
{tabs.map(f => {
|
||||
const active = filter === f.id
|
||||
return (
|
||||
@@ -107,18 +191,20 @@ export function PlacesHeader(S: SidebarState) {
|
||||
className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '4px 9px', borderRadius: 99,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
|
||||
padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap',
|
||||
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.label}</span>
|
||||
<span className={active ? 'text-accent-text' : 'text-content-faint'} style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1,
|
||||
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
|
||||
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{counts[f.id]}
|
||||
</span>
|
||||
@@ -129,6 +215,42 @@ export function PlacesHeader(S: SidebarState) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Says out loud what the count above already narrowed to.
|
||||
The map has followed the open day on this filter since #2024, and until now
|
||||
nothing anywhere said so: the pool read 55, the map drew five, and the honest
|
||||
conclusion was that the map was broken. It sits under the tabs rather than on
|
||||
the map because a chip over the canvas is unreachable on a phone, which is
|
||||
where this was reported from. */}
|
||||
{dayScoped && (
|
||||
<div
|
||||
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 6px 4px 9px', borderRadius: 8, marginBottom: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={11} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t('places.dayScoped')}
|
||||
</span>
|
||||
{onClearSelectedDay && (
|
||||
<button type="button"
|
||||
onClick={onClearSelectedDay}
|
||||
aria-label={t('places.dayScopedClear')}
|
||||
title={t('places.dayScopedClear')}
|
||||
className="text-content-faint hover:text-content"
|
||||
style={{
|
||||
marginInlineStart: 'auto', display: 'flex', alignItems: 'center',
|
||||
background: 'none', border: 'none', padding: 3, cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<X size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suchfeld */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
|
||||
@@ -137,10 +259,14 @@ export function PlacesHeader(S: SidebarState) {
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
placeholder={t('places.search')}
|
||||
className="bg-surface-tertiary text-content"
|
||||
className="bg-surface-card text-content"
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
// Borderless on the tertiary surface it sat almost flush with the
|
||||
// panel; the outline is the one the category dropdown below already
|
||||
// uses, so the two read as the same kind of control.
|
||||
border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment } from 'react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { MemoPlaceRow } from './PlacesSidebarRow'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
@@ -14,14 +15,26 @@ export function PlacesList(S: SidebarState) {
|
||||
return (
|
||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
</span>
|
||||
{canEditPlaces && <button type="button" onClick={onAddPlace} className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('places.addPlace')}
|
||||
</button>}
|
||||
</div>
|
||||
/* The mascot and one line, the shape every other empty state in TREK has.
|
||||
The add link stays as the state's action: an empty list of places is
|
||||
one of the few that has an obvious next step. */
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
title={filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
action={canEditPlaces ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddPlace}
|
||||
className="text-caption text-content underline underline-offset-2 hover:text-accent"
|
||||
>
|
||||
{t('places.addPlace')}
|
||||
</button>
|
||||
) : undefined}
|
||||
/>
|
||||
) : (
|
||||
filtered.map(place => {
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
|
||||
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
|
||||
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
|
||||
@@ -31,6 +31,21 @@ export interface PlacesSidebarProps {
|
||||
selectedPlaceId: number | null
|
||||
onPlaceClick: (placeId: number | null) => void
|
||||
onAddPlace: () => void
|
||||
/**
|
||||
* Create a place and drop it straight into the day that is open.
|
||||
*
|
||||
* Only reachable while a day is selected, which is what the split button in
|
||||
* the header is about: the pool is one click away from the plan, and adding a
|
||||
* place you already know belongs to today should not need a second trip
|
||||
* through the day picker.
|
||||
*/
|
||||
onAddPlaceToSelectedDay?: () => void
|
||||
/**
|
||||
* Close the open day, from the note that says the pool is showing only that day.
|
||||
* Absent leaves the note without its dismiss, which is what the mobile day picker
|
||||
* wants: there the day is closed by the picker itself.
|
||||
*/
|
||||
onClearSelectedDay?: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
@@ -51,7 +66,7 @@ export interface PlacesSidebarProps {
|
||||
*/
|
||||
export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const {
|
||||
tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS,
|
||||
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
|
||||
pushUndo, initialScrollTop, onScrollTopChange,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
@@ -229,10 +244,30 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
[assignments, accommodations, reservations],
|
||||
)
|
||||
|
||||
/**
|
||||
* What "planned" means while a day is open: that day's plan, not the whole trip's.
|
||||
*
|
||||
* The map has narrowed to the selected day since #2024, and the list did not, which is
|
||||
* how a trip with 55 planned places showed 55 in the pool and five pins on the map with
|
||||
* nothing to explain the gap — read, reasonably, as the map being broken. The list now
|
||||
* follows the map, and `dayScoped` tells the header to say so.
|
||||
*
|
||||
* Only "planned" narrows. "Unplanned" stays trip-wide on purpose: a place assigned to
|
||||
* some other day is planned, whichever day happens to be open.
|
||||
*/
|
||||
const plannedInDayIds = useMemo(
|
||||
() => (selectedDayId
|
||||
? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations, reservations })
|
||||
: null),
|
||||
[selectedDayId, days, assignments, accommodations, reservations],
|
||||
)
|
||||
const plannedFilterIds = plannedInDayIds ?? plannedIds
|
||||
const dayScoped = filter === 'planned' && plannedInDayIds !== null
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
@@ -245,7 +280,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
return true
|
||||
})
|
||||
return list
|
||||
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
|
||||
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter])
|
||||
|
||||
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
@@ -313,7 +348,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
markSelectionVisited, markVisitedBusy,
|
||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
hasTracks, plannedIds, plannedFilterIds, dayScoped, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -702,6 +702,8 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
}}
|
||||
onError={() => setLoadFailed(true)}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
allowFullScreen
|
||||
allow="fullscreen"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
title={title || pluginId}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import AnchoredPopover from './AnchoredPopover'
|
||||
|
||||
/**
|
||||
* FE-ANCHOREDPOP-001..014 — where the panel lands and what makes it go away.
|
||||
*
|
||||
* Everything here is geometry read off the page and arithmetic nobody sees until it is
|
||||
* wrong: a panel a gap below the badge, above it on a short window, pulled back inside
|
||||
* the viewport, centred on the rail rather than on the badge. The numbers are worked out
|
||||
* in the comments so a changed rule fails with a readable diff instead of a shrug.
|
||||
*
|
||||
* jsdom lays nothing out — every getBoundingClientRect comes back as zeroes, so badge,
|
||||
* rail and panel all measure the same and every branch of the placement collapses onto
|
||||
* 0,0. Sizes are therefore fed in per element, and window.innerHeight / innerWidth are
|
||||
* stated rather than left to the jsdom default, since the flip and the clamp are the
|
||||
* whole point of half these cases.
|
||||
*/
|
||||
|
||||
const rect = (top: number, left: number, width: number, height: number): DOMRect => ({
|
||||
top, left, width, height,
|
||||
bottom: top + height,
|
||||
right: left + width,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
|
||||
let section: HTMLElement
|
||||
let anchor: HTMLElement
|
||||
let panel: DOMRect
|
||||
|
||||
function viewport(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
section = document.createElement('section')
|
||||
anchor = document.createElement('button')
|
||||
section.appendChild(anchor)
|
||||
document.body.appendChild(section)
|
||||
// The rail as it stands on a desktop: a tall column at the left edge with the badge
|
||||
// hard against its far side. That offset is what the centring rule exists for.
|
||||
section.getBoundingClientRect = () => rect(100, 0, 400, 600)
|
||||
anchor.getBoundingClientRect = () => rect(120, 344, 48, 20)
|
||||
panel = rect(0, 0, 200, 150)
|
||||
viewport(1200, 800)
|
||||
// The panel is the one element that cannot be primed by hand: React makes it inside the
|
||||
// portal and the layout effect measures it in the tick it is created. So it answers off
|
||||
// the prototype, which the two own assignments above shadow. Only its size is read —
|
||||
// handing back a top and a left it does not have yet would make the test circular.
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => panel)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Both, because case 006 moves the anchor out of the section to test the fallback, so
|
||||
// it would outlive section.remove() and sit in the document for the rest of the file.
|
||||
anchor.remove()
|
||||
section.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function open() {
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
<AnchoredPopover anchor={anchor} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
return { onClose, unmount: view.unmount, rerender: view.rerender }
|
||||
}
|
||||
|
||||
const dialog = () => screen.getByRole('dialog')
|
||||
|
||||
describe('AnchoredPopover', () => {
|
||||
it('FE-ANCHOREDPOP-001: without an anchor there is nothing to hang under, and nothing listening', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<AnchoredPopover anchor={null} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
// A closed panel that keeps its key handler takes Escape away from whatever is
|
||||
// genuinely open behind it.
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-002: it hangs a gap under the badge, and is shown only once it has been placed', () => {
|
||||
open()
|
||||
|
||||
// Badge bottom 140 plus the gap. Fixed is asserted as the class rather than as a
|
||||
// computed style, because jsdom resolves no stylesheet: it is what makes every
|
||||
// number in this file a viewport coordinate instead of an offset inside a scroller.
|
||||
expect(dialog()).toHaveStyle({ top: '148px' })
|
||||
expect(dialog().className).toContain('fixed')
|
||||
expect(screen.getByRole('dialog', { name: 'Stop kind' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-015: it is portalled to the body, out of the rail that would clip it', () => {
|
||||
open()
|
||||
|
||||
// The reason this component exists at all. The rail scrolls and carries transforms,
|
||||
// and both of those trap a fixed child, so a panel rendered where it is declared
|
||||
// would be clipped by the sidebar it hangs in. Every other case here passes without
|
||||
// the portal, which is why this one asserts the parent rather than the geometry.
|
||||
expect(dialog().parentElement).toBe(document.body)
|
||||
expect(section.contains(dialog())).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-016: moving to another badge re-measures against that one', () => {
|
||||
const { onClose, rerender } = open()
|
||||
|
||||
const second = document.createElement('button')
|
||||
section.appendChild(second)
|
||||
second.getBoundingClientRect = () => rect(500, 344, 48, 20)
|
||||
rerender(
|
||||
<AnchoredPopover anchor={second} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
|
||||
// 500 plus 20 plus the gap. The rail has more than one badge worth opening and the
|
||||
// same panel serves all of them, so the anchor really does change under a mounted
|
||||
// popover. Measuring in a layout effect is what keeps that from ever being painted
|
||||
// at the previous badge: the position is committed before the browser draws.
|
||||
expect(dialog()).toHaveStyle({ top: '528px' })
|
||||
second.remove()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-003: on a short window it flips above the badge instead of off the bottom', () => {
|
||||
anchor.getBoundingClientRect = () => rect(200, 344, 48, 20)
|
||||
viewport(1200, 360)
|
||||
|
||||
open()
|
||||
|
||||
// Below would start at 228 and run to 378, past the 352 the window leaves. Above is
|
||||
// badge top 200 less the 150 it needs less the gap.
|
||||
expect(dialog()).toHaveStyle({ top: '42px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-004: a panel too tall for either side sits at the top edge rather than above it', () => {
|
||||
anchor.getBoundingClientRect = () => rect(40, 344, 48, 20)
|
||||
panel = rect(0, 0, 200, 250)
|
||||
viewport(1200, 320)
|
||||
|
||||
open()
|
||||
|
||||
// Flipping honestly would put it at -218, scrolling the half that holds the controls
|
||||
// out of reach with nothing to scroll back. Covering the badge is the lesser evil.
|
||||
expect(dialog()).toHaveStyle({ top: '8px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-005: it centres on the rail, not on the badge sitting at the edge of it', () => {
|
||||
open()
|
||||
|
||||
// Rail centre 200 less half the panel. Centred on the badge it would be 268, hanging
|
||||
// over the map and pointing at nothing.
|
||||
expect(dialog()).toHaveStyle({ left: '100px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-006: with no column around it, it falls back to the badge', () => {
|
||||
// Same badge, no rail: the panel is not owned by the sidebar and has to place itself
|
||||
// sanely wherever it is hung.
|
||||
document.body.appendChild(anchor)
|
||||
|
||||
open()
|
||||
|
||||
// Badge centre 368 less half the panel.
|
||||
expect(dialog()).toHaveStyle({ left: '268px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-007: a rail near the right edge does not push the panel off the screen', () => {
|
||||
section.getBoundingClientRect = () => rect(100, 380, 200, 600)
|
||||
viewport(500, 800)
|
||||
|
||||
open()
|
||||
|
||||
// Centred it would want 380 and end at 580, well past a window 500 wide. Clamped it
|
||||
// ends at 492, the same margin the rest of the placement keeps.
|
||||
expect(dialog()).toHaveStyle({ left: '292px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-008: a rail scrolled off the left does not push the panel off it either', () => {
|
||||
section.getBoundingClientRect = () => rect(100, -60, 100, 600)
|
||||
|
||||
open()
|
||||
|
||||
// Centred on that sliver the panel would start at -110 and lose its first half.
|
||||
expect(dialog()).toHaveStyle({ left: '8px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-009: Escape closes it, and only Escape', () => {
|
||||
// The handler sits on the document, so it sees every key pressed anywhere on the
|
||||
// page, typing in a field included.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
fireEvent.keyDown(document, { key: 'e' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-010: a press anywhere else closes it', () => {
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-011: a press on what the panel contains leaves it open', () => {
|
||||
// It closes on mousedown, not on click. Without the guard a button in the panel would
|
||||
// be torn out from under the pointer before its own click ever landed.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(screen.getByText('Inside'))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-012: a press on the badge that opened it leaves it open too', () => {
|
||||
// The badge toggles. Closing here as well means one press closes the panel twice, and
|
||||
// the next press reads as the badge refusing to open.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(anchor)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-013: a scroll closes it, including one that never bubbles up', () => {
|
||||
// Scroll events do not bubble and the rail is its own scroller. A listener without
|
||||
// capture would hear nothing while the badge slides out from under a panel that stays
|
||||
// where it was measured.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.scroll(section)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-014: once it is gone it has stopped listening', () => {
|
||||
// Three handlers on the document and the window, on a component that is mounted and
|
||||
// thrown away again for every badge in the rail.
|
||||
const { onClose, unmount } = open()
|
||||
|
||||
unmount()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.mouseDown(document.body)
|
||||
fireEvent.scroll(section)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/**
|
||||
* A small panel hung under something in the rail.
|
||||
*
|
||||
* The rail has more than one badge worth opening — what kind of stop this is, how full it
|
||||
* fills — and every one of them needs the same four things: measure before showing,
|
||||
* flip above when the window is short, close on an outside click or Escape, and close on
|
||||
* a scroll rather than chase the anchor. Each is one line to get wrong and none of them
|
||||
* is about what the panel contains, which is why they live here once instead of beside
|
||||
* every panel that needs them.
|
||||
*
|
||||
* Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a
|
||||
* search and a keyboard model. This is a surface.
|
||||
*/
|
||||
export default function AnchoredPopover({ anchor, label, children, onClose }: {
|
||||
/** The element the panel hangs under. Null renders nothing, which is how it stays closed. */
|
||||
anchor: HTMLElement | null
|
||||
/** What the dialog is called, for anyone who cannot see where it is hanging. */
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Measured after paint, before the browser shows it: reading the size first and then
|
||||
// placing it is what stops the panel appearing at 0,0 for one frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const p = ref.current.getBoundingClientRect()
|
||||
const gap = 8
|
||||
let top = a.bottom + gap
|
||||
// Flips above when there is no room below, which is most of the rail on a short
|
||||
// window: a panel clipped by the viewport is one that cannot be used at all.
|
||||
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
||||
// Centred on the column, not on the anchor. A badge sits hard against one edge of the
|
||||
// rail, so a panel centred on it hangs half off the sidebar and points at the map.
|
||||
// Vertically it still follows the row it belongs to.
|
||||
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
||||
const left = Math.min(
|
||||
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
||||
window.innerWidth - p.width - 8,
|
||||
)
|
||||
setPos({ top, left })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (anchor.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Any scroll moves the anchor out from under the panel, and following it would mean
|
||||
// measuring on every frame for a menu that is open for two seconds.
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!anchor) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
||||
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import RangeStrip, { blockMask } from './RangeStrip'
|
||||
|
||||
/**
|
||||
* FE-RANGESTRIP-001..015 — the picture under the range field.
|
||||
*
|
||||
* Nothing here is interactive, so what is worth pinning is the arithmetic the drawing
|
||||
* rests on: the empty state has to stay empty rather than settle on a plausible zero,
|
||||
* the block mask has a band it lives in and looks broken outside it, and the three
|
||||
* segments have to cover the bar exactly or the tail runs off the end. The chips are the
|
||||
* only readable proof that any of that happened, and the aria-label is the only route a
|
||||
* screen reader has to the figure at all.
|
||||
*/
|
||||
|
||||
const strip = (over: Partial<React.ComponentProps<typeof RangeStrip>> = {}) => (
|
||||
<RangeStrip
|
||||
rangeKm={500}
|
||||
fillPercent={null}
|
||||
wearPercent={null}
|
||||
unit={'metric' as DistanceUnit}
|
||||
electric={false}
|
||||
{...over}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* The three widths of the bar, in the order they are laid: what a stop puts in, what is
|
||||
* left of the tank, what age has taken. Read off the track rather than off the wrapper,
|
||||
* because the wrapper is the thing carrying the label.
|
||||
*/
|
||||
function segments(): number[] {
|
||||
const track = screen.getByRole('img').firstElementChild as HTMLElement
|
||||
return Array.from(track.children).map(c => parseFloat((c as HTMLElement).style.width))
|
||||
}
|
||||
|
||||
describe('RangeStrip', () => {
|
||||
it('FE-RANGESTRIP-001: with no range the figure is a dash and no unit hangs off it', () => {
|
||||
// A lone "km" beside a dash reads as a unit waiting for a number, which invites the
|
||||
// guess that the number is zero. There is no number, so there is nothing to label.
|
||||
render(strip({ rangeKm: null }))
|
||||
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
expect(screen.queryByText('km')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No range set')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Type a range/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-002: with no range the bar reads out the hint alone', () => {
|
||||
// The bar is drawn empty here, so its label is the only thing that can say why.
|
||||
render(strip({ rangeKm: null }))
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName(
|
||||
'Type a range, or fill in the figures below and TREK works it out.',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-003: the figure and its unit are split but say the same thing', () => {
|
||||
// Split so the unit can sit smaller beside the number; the split is only safe while
|
||||
// the two halves still read as one reading of one distance.
|
||||
render(strip({ rangeKm: 500 }))
|
||||
|
||||
expect(screen.getByText('500')).toBeInTheDocument()
|
||||
expect(screen.getByText('km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-004: an electric car charges where a petrol one fills', () => {
|
||||
const { rerender } = render(strip({ electric: true }))
|
||||
expect(screen.getByText('per charge')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ electric: false }))
|
||||
expect(screen.getByText('per tank')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-005: a countable range is cut into blocks and says what one is worth', () => {
|
||||
// Ten blocks. The chip is the only thing that turns the gaps from texture into a
|
||||
// measure, so it has to appear with the mask rather than merely near it.
|
||||
render(strip({ rangeKm: 500 }))
|
||||
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-006: a range too short or too long to count is left solid', () => {
|
||||
// Two blocks read as damage; forty eat the bar. Either way there is nothing to
|
||||
// count, so claiming a block is worth fifty kilometres would be claiming a mask
|
||||
// that was never cut.
|
||||
const { rerender } = render(strip({ rangeKm: 100 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 2000 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-007: the countable band has hard edges at three and thirty blocks', () => {
|
||||
// Rounded, not floored: 130 km is three blocks and 1520 km is thirty. The pairs
|
||||
// either side of each edge are what a change to that rounding would move.
|
||||
const { rerender } = render(strip({ rangeKm: 120 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 130 }))
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 1520 }))
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 1560 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-008: a partial fill says what that stop actually buys', () => {
|
||||
// Four fifths of a 500 km tank. The whole point of the panel is that nobody charges
|
||||
// to the top, so the note has to move off the full-tank sentence.
|
||||
render(strip({ rangeKm: 500, fillPercent: 80 }))
|
||||
|
||||
expect(screen.getByText('A 80 % stop gives 400 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText('A stop fills right up')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-009: a full, an absent and an impossible fill all read as filling right up', () => {
|
||||
// Anything outside nought to a hundred is not a fraction of a tank, and a note
|
||||
// saying a 130 % stop gives 650 km would be the panel inventing range.
|
||||
const { rerender } = render(strip({ fillPercent: 100 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: null }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: 130 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
// The other end of the same guard, and the one a cleared or mistyped field really
|
||||
// produces: a stop that puts nothing in would leave the bar empty and the note
|
||||
// claiming a range of nought.
|
||||
rerender(strip({ fillPercent: 0 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: -20 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-010: wear is clamped to 90 rather than drawn past the bar', () => {
|
||||
// A battery reported as 150 % gone would leave the middle segment at a negative
|
||||
// width, which the browser drops: the tail would then be the whole bar and the chip
|
||||
// would be telling the traveller the car cannot move.
|
||||
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 150 }))
|
||||
|
||||
expect(screen.getByText('90 % lost to age')).toBeInTheDocument()
|
||||
expect(segments()).toEqual([8, 2, 90])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-011: with nothing lost to age there is no tail to explain', () => {
|
||||
// Zero is the ordinary case for a new car and for every petrol one. A chip reading
|
||||
// "0 % lost to age" is an answer to a question nobody asked.
|
||||
const { rerender } = render(strip({ wearPercent: 0 }))
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ wearPercent: -20 }))
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
expect(segments()).toEqual([100, 0, 0])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-012: without a range there is no wear to take off it', () => {
|
||||
// Wear is a share of a range. With no range the share is of nothing, and a tail on
|
||||
// an empty bar would be the one filled thing on it.
|
||||
render(strip({ rangeKm: null, wearPercent: 30 }))
|
||||
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
expect(segments()).toEqual([0, 100, 0])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-013: the three segments cover the bar exactly', () => {
|
||||
// A fifth gone and a four-fifths stop: the fill is four fifths of what is LEFT, not
|
||||
// of the factory bar, or the filled part would reach into the hatched tail.
|
||||
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 20 }))
|
||||
|
||||
expect(segments()).toEqual([64, 16, 20])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-016: the stencil itself cuts one block per fifty kilometres', () => {
|
||||
// Asserted on the function, because jsdom implements no mask property: React writes
|
||||
// neither maskImage nor WebkitMaskImage and the element arrives with no style at
|
||||
// all, so through the DOM every one of these cases would pass on an empty bar.
|
||||
expect(blockMask(500)).toContain('repeating-linear-gradient')
|
||||
// Ten blocks of fifty, so one keep-then-cut step is a tenth of the bar.
|
||||
expect(blockMask(500)).toContain('10%')
|
||||
// Five blocks, a fifth each: the step really follows the distance rather than being
|
||||
// a fixed number of divisions dressed up as one.
|
||||
expect(blockMask(250)).toContain('20%')
|
||||
|
||||
// Below three the cuts read as damage and above thirty the gaps eat the bar, so
|
||||
// outside that the bar is honestly solid.
|
||||
expect(blockMask(100)).toBeUndefined()
|
||||
expect(blockMask(2000)).toBeUndefined()
|
||||
expect(blockMask(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-017: the wear chip carries the same hatching as the tail of the bar', () => {
|
||||
// The swatch is what ties the sentence to the stripe. Without it the hatching is an
|
||||
// unexplained texture at the end of the bar and the chip an unexplained subtraction.
|
||||
const { container } = render(strip({ rangeKm: 400, wearPercent: 12, electric: true }))
|
||||
|
||||
const hatched = Array.from(container.querySelectorAll<HTMLElement>('span[style*="repeating-linear-gradient"]'))
|
||||
expect(hatched).toHaveLength(1)
|
||||
expect(hatched[0].style.backgroundImage).toBe(
|
||||
(screen.getByRole('img').firstElementChild?.lastElementChild as HTMLElement).style.backgroundImage,
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-014: the bar reads out the figure and the note together', () => {
|
||||
// Everything else in here is colour and width. A screen reader gets the range from
|
||||
// this label or not at all, so it has to carry both halves of the answer.
|
||||
render(strip({ rangeKm: 500, fillPercent: 80 }))
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName('500 km — A 80 % stop gives 400 km')
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-015: imperial converts the figure, the unit and the block chip together', () => {
|
||||
// The blocks are cut in kilometres whatever the setting, so the chip is the one
|
||||
// place the two systems can drift apart: 50 km of bar has to be stated as 31.1 mi.
|
||||
render(strip({ rangeKm: 500, unit: 'imperial' as DistanceUnit }))
|
||||
|
||||
expect(screen.getByText('310.7')).toBeInTheDocument()
|
||||
expect(screen.getByText('mi')).toBeInTheDocument()
|
||||
expect(screen.getByText('One block = 31.1 mi')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* How far the car goes on one fill, drawn as the tank it leaves the factory with.
|
||||
*
|
||||
* The figure above it is the answer; this is the argument that the figure is right. It
|
||||
* carries the two things a bare number cannot say on its own:
|
||||
*
|
||||
* - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the
|
||||
* road, and a traveller who set 80 % is looking at four fifths of a bar, which is the
|
||||
* honest picture of what the next leg has.
|
||||
* - The hatched tail is what age has taken off an electric car's battery. It is already
|
||||
* out of the number above, so without the tail that figure looks like an unexplained
|
||||
* disagreement with the car's own data sheet.
|
||||
*
|
||||
* Widths animate because this sits directly under the fields that feed it: typing a
|
||||
* consumption and watching the bar move is what makes the two ways of stating a range
|
||||
* visibly the same thing.
|
||||
*
|
||||
* No box, no border, no tinted panel. It used to sit in a card of its own, which made it
|
||||
* a second surface inside a dialog whose whole point is one plain column — and the box
|
||||
* was doing nothing the bar was not already doing better.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One reading of the figure above it.
|
||||
*
|
||||
* `bg-surface-secondary` rather than the card's own colour: these sit ON the card, so the
|
||||
* chip has to be a step away from it in whichever scheme is on, and a white literal would
|
||||
* be a step the wrong way in the dark one.
|
||||
*/
|
||||
const NOTE_CHIP = 'inline-flex items-center rounded-lg bg-surface-secondary px-2 py-1 text-caption leading-snug text-content-secondary'
|
||||
|
||||
/** How many kilometres one block of the bar stands for. */
|
||||
const BLOCK_KM = 50
|
||||
|
||||
/**
|
||||
* The bar, cut into blocks of a fixed distance rather than into quarters.
|
||||
*
|
||||
* Quarters divide a bar; blocks measure a road. With one block per fifty kilometres the
|
||||
* bar for a 240 km car is visibly shorter than the bar for a 600 km one, which is the
|
||||
* comparison somebody typing a consumption is actually making — and it turns the bar into
|
||||
* something countable rather than something to eyeball.
|
||||
*
|
||||
* Cut as a MASK rather than painted over: the old quarter marks were drawn in the panel's
|
||||
* own colour to look like gaps, which tied them to that panel and left them visible as
|
||||
* stripes anywhere else. A mask makes real holes, so whatever is behind the bar shows
|
||||
* through and nothing has to know the background.
|
||||
*
|
||||
* Exported for its own test rather than checked through the bar: jsdom supports no mask
|
||||
* property at all, so React writes neither of the two and the element reaches a test
|
||||
* carrying no style whatsoever. Through the DOM the rule is unobservable.
|
||||
*/
|
||||
export function blockMask(rangeKm: number | null): string | undefined {
|
||||
if (!rangeKm) return undefined
|
||||
const blocks = Math.round(rangeKm / BLOCK_KM)
|
||||
// Below three the cuts read as damage rather than as measure, and above about thirty
|
||||
// the gaps eat the bar. Outside that the bar is simply solid, which is honest: at that
|
||||
// range the blocks were never going to be counted.
|
||||
if (blocks < 3 || blocks > 30) return undefined
|
||||
const step = 100 / blocks
|
||||
// Straight, not slanted. The slant was tried and read as a decorative texture rather
|
||||
// than as a measure: on a bar this thin the lean is legible only as fuzz on the seams.
|
||||
//
|
||||
// The colour is opacity here, not paint: black is "keep", transparent is "cut".
|
||||
return `repeating-linear-gradient(90deg, #000 0 calc(${step}% - 2px), transparent calc(${step}% - 2px) ${step}%)` // theme-lint-disable — mask stencil, not a colour
|
||||
}
|
||||
|
||||
export default function RangeStrip({ rangeKm, fillPercent, wearPercent, unit, electric }: {
|
||||
rangeKm: number | null
|
||||
fillPercent: number | null
|
||||
wearPercent: number | null
|
||||
unit: DistanceUnit
|
||||
electric: boolean
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
// Capped where the arithmetic caps it, so the picture cannot claim a battery is gone.
|
||||
const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0
|
||||
const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100
|
||||
const usable = 100 - wear
|
||||
const filled = rangeKm ? (usable * fill) / 100 : 0
|
||||
const rest = 100 - wear - filled
|
||||
// Split rather than formatted whole, so the unit can sit smaller beside the figure. The
|
||||
// rounding is formatDistance's own, because the badge on the trigger uses that and the
|
||||
// two must not disagree by a tenth.
|
||||
const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null
|
||||
const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)'
|
||||
const mask = blockMask(rangeKm)
|
||||
|
||||
const note = shown === null
|
||||
? t('roadtrip.limit.rangeEmptyHint')
|
||||
: fill < 100
|
||||
? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) })
|
||||
: t('roadtrip.limit.fullNote')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
{/* The figure and what it is a figure OF, on one baseline. Wrapping rather than
|
||||
truncating: "per charge" is three words in English and six in Hungarian. */}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="font-semibold tabular-nums leading-none text-content" style={{ fontSize: FS.total }}>
|
||||
{shown ?? '—'}
|
||||
</span>
|
||||
{shown !== null ? (
|
||||
<span className="font-semibold leading-none text-content-muted" style={{ fontSize: FS.totalUnit }}>
|
||||
{getDistanceUnitLabel(unit)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-caption text-content-faint">
|
||||
{shown === null
|
||||
? t('roadtrip.limit.rangeEmpty')
|
||||
: t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2.5 w-full overflow-hidden rounded-full bg-surface"
|
||||
role="img"
|
||||
aria-label={shown === null ? note : `${shown} ${getDistanceUnitLabel(unit)} — ${note}`}
|
||||
>
|
||||
<div className="flex h-full w-full" style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined} aria-hidden>
|
||||
<div
|
||||
className="h-full transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${filled}%`, background: 'linear-gradient(90deg, var(--accent-hover), var(--accent))' }}
|
||||
/>
|
||||
<div className="h-full transition-[width] duration-500 ease-out" style={{ width: `${rest}%` }} />
|
||||
<div
|
||||
className="h-full transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${wear}%`, backgroundImage: stripes }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three readings of this one figure — what a stop actually puts in, what one block
|
||||
on the bar stands for, what age has taken off — each on its own chip inside the
|
||||
card the number and the bar already share. As prose on one line they ran into
|
||||
each other; given the card to themselves they looked like four separate answers.
|
||||
Chips inside the card are both: three facts, one answer. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className={NOTE_CHIP}>{note}</span>
|
||||
{mask ? (
|
||||
<span className={NOTE_CHIP}>{t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })}</span>
|
||||
) : null}
|
||||
{/* The swatch is what ties the chip to the tail of the bar. Without it the
|
||||
hatching is an unexplained stripe and the sentence an unexplained loss. */}
|
||||
{wear > 0 ? (
|
||||
<span className={`${NOTE_CHIP} gap-1.5`}>
|
||||
<span className="h-2 w-3.5 shrink-0 rounded-sm" style={{ backgroundImage: stripes }} aria-hidden />
|
||||
{t('roadtrip.limit.wearNote', { percent: wear })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
|
||||
|
||||
const stop = (id: number, name: string): RoadtripStop => ({
|
||||
assignmentId: id,
|
||||
ownerDayId: 1,
|
||||
ownerIndex: id - 1,
|
||||
placeId: id * 10,
|
||||
name,
|
||||
lat: 53.5,
|
||||
@@ -41,7 +43,7 @@ const day = (dayId: number, dayNumber: number): RoadtripDay => ({
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days,
|
||||
lines: [],
|
||||
lines: [], lineDays: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
segments: [],
|
||||
@@ -202,9 +204,9 @@ describe('RoadtripCorridorPanel', () => {
|
||||
})
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// "Fuel" also names an option in the kind picker; the group header is the one
|
||||
// inside a <header>.
|
||||
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)!
|
||||
// "Fuel" also names an option in the kind picker; the group header is the one that
|
||||
// folds its list, so it is the button.
|
||||
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('button')).find(Boolean)!
|
||||
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
|
||||
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
|
||||
})
|
||||
@@ -291,11 +293,17 @@ describe('RoadtripCorridorPanel', () => {
|
||||
expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => {
|
||||
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is not reported as a fault', () => {
|
||||
// It used to say so, and it said so on nearly every search of a long day: the ceiling
|
||||
// is per box and a busy corridor reaches it as a matter of course. That made a
|
||||
// permanent complaint about a search that had worked, and its advice — narrow the
|
||||
// corridor — makes the answer smaller rather than better. The warnings that remain
|
||||
// are the ones about a search that did not happen.
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/had more than fits in one answer/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
|
||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
@@ -85,6 +85,14 @@ function ResultBadge({ category }: { category: string }): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One fact about a hit, on its own surface.
|
||||
*
|
||||
* `bg-surface-secondary` rather than a literal: the row lifts to `surface-hover` under the
|
||||
* pointer, and a chip painted white would stop lifting with it in the dark scheme.
|
||||
*/
|
||||
const POI_CHIP = 'inline-flex items-center rounded-md bg-surface-secondary px-1.5 py-0.5 leading-none tabular-nums text-content-muted'
|
||||
|
||||
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
@@ -95,16 +103,16 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
|
||||
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{poi.name}
|
||||
</div>
|
||||
{/* Two facts, two elements. Both are translated phrases rather than short fixed
|
||||
values, so they stay text and the gap does the separating a middot used to —
|
||||
which also lets them wrap onto their own lines instead of breaking around a
|
||||
dot left stranded at the end of a line. */}
|
||||
<div
|
||||
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
|
||||
<span>
|
||||
{/* Two facts, two chips. Where it is off the road and how far into the drive it
|
||||
comes are separate answers, and as two phrases sharing a line they read as one
|
||||
run-on sentence about the same thing. A chip each gives them an edge, and the
|
||||
row still wraps cleanly at a narrow width because nothing has to break around
|
||||
a separator. */}
|
||||
<div className="flex flex-wrap items-center gap-1" style={{ fontSize: FS.meta }}>
|
||||
<span className={POI_CHIP}>
|
||||
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
|
||||
</span>
|
||||
<span className={POI_CHIP}>
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
@@ -167,9 +175,30 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
const meta = CATEGORY_META[category]
|
||||
const Icon = meta?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
/**
|
||||
* Folded away, per category.
|
||||
*
|
||||
* A search for fuel AND food comes back as two lists that push each other off the
|
||||
* screen, and the one being read is never the one at the top. Local state on purpose:
|
||||
* which group somebody has open right now is a way of looking, not a setting — it
|
||||
* should not outlive the search that produced these groups.
|
||||
*/
|
||||
const [open, setOpen] = useState(true)
|
||||
const label = meta ? t(meta.labelKey) : category
|
||||
return (
|
||||
<section className="pb-1">
|
||||
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
|
||||
// The gap between two groups belongs to the section, not to the strip: as padding on
|
||||
// the strip it became part of the hover surface, which then hung well above the row
|
||||
// it highlights.
|
||||
<section className="pb-1 pt-1.5 first:pt-0">
|
||||
{/* The whole strip is the control, not a chevron at its end: it is what the eye is
|
||||
on when it decides this is not the list it wants, and at this width a 12px
|
||||
target in the corner is a miss waiting to happen. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
className="group/cat flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
// theme-lint-disable — see ResultBadge.
|
||||
@@ -178,13 +207,21 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
|
||||
{meta ? t(meta.labelKey) : category}
|
||||
{label}
|
||||
</span>
|
||||
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
|
||||
{t('roadtrip.poi.found', { count: pois.length })}
|
||||
</span>
|
||||
</header>
|
||||
<ul>
|
||||
{/* Quiet until the strip is under the pointer, then it says which way this goes.
|
||||
A chevron that is always at full contrast reads as a fourth piece of data in a
|
||||
header that already carries three. */}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`shrink-0 text-content-faint transition-all group-hover/cat:text-content ${open ? '' : '-rotate-90'}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<ul hidden={!open}>
|
||||
{pois.map(poi => (
|
||||
<ResultRow
|
||||
key={poi.osm_id}
|
||||
@@ -267,9 +304,11 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
// Some boxes answered and some did not. Saying so is the difference between "there is
|
||||
// no fuel on this stretch" and "nobody looked at this stretch".
|
||||
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
|
||||
// The stretch was searched and the answer came back short. Without this line a filter
|
||||
// finding nothing looks like proof there is nothing.
|
||||
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
|
||||
// A stretch that answered short is deliberately NOT reported. It fired on almost every
|
||||
// search of a long day — the ceiling is per box and a busy corridor reaches it easily —
|
||||
// so it read as a permanent complaint about a search that had in fact worked, and the
|
||||
// advice it gave ("narrow the corridor") makes the answer smaller rather than better.
|
||||
// The two warnings that remain are about a search that did not happen.
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
|
||||
@@ -328,12 +367,16 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
|
||||
{/* One word, so it can be read at the size a primary action deserves. The panel
|
||||
it sits in is headed "along the route" and every control above it narrows the
|
||||
same search; repeating that on the button only made it small. */}
|
||||
same search; repeating that on the button only made it small.
|
||||
|
||||
Its height is the controls' height, not a size of its own. At 38px it stood a
|
||||
head above the segmented rows it follows and read as a second panel rather
|
||||
than as the end of this one. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.search}
|
||||
disabled={!canSearch}
|
||||
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
|
||||
className="flex h-[32px] w-full items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{search.loading
|
||||
? <RotateCw size={15} className="animate-spin" aria-hidden />
|
||||
@@ -509,7 +552,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
||||
scene={search.loading ? 'search' : 'idle'}
|
||||
mood={filteredToNothing ? 'confused' : undefined}
|
||||
size={88}
|
||||
compact
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
title={
|
||||
search.loading
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripLimitsCard from './RoadtripLimitsCard'
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
|
||||
* FE-ROADTRIP-LIMITS-001..012 — the numbers that decide every warning.
|
||||
*
|
||||
* The one that matters is when a number is written. These fields had no local
|
||||
* state, so every keystroke was a settings PUT: typing "180" sent three, and
|
||||
@@ -13,14 +13,26 @@ import RoadtripLimitsCard from './RoadtripLimitsCard'
|
||||
* tenth of what was typed and only say so after the next reload.
|
||||
*/
|
||||
|
||||
function open(onSave?: (key: string, value: number) => void) {
|
||||
function open(onSave?: (key: string, value: number | string | boolean) => void) {
|
||||
render(<RoadtripLimitsCard onSave={onSave} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}
|
||||
|
||||
/** The three inputs, in the order the dialog lists them. */
|
||||
/** Every number field, in the order the dialog lists them. */
|
||||
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
|
||||
/**
|
||||
* Unfolds the car's own figures.
|
||||
*
|
||||
* They sit behind a disclosure now, open from the start only when one of them is already
|
||||
* stored — so a test that arrives with an empty vehicle has to ask for them, and one that
|
||||
* arrives with values does not.
|
||||
*/
|
||||
function openSpec() {
|
||||
const toggle = screen.queryByRole('button', { name: /Work it out from the car/i })
|
||||
if (toggle && toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
@@ -127,4 +139,125 @@ describe('RoadtripLimitsCard', () => {
|
||||
// handler to call, and must not assume one.
|
||||
expect(() => fireEvent.blur(leg)).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_vehicle: 'electric', distance_unit: 'metric' } as never,
|
||||
})
|
||||
open(vi.fn())
|
||||
|
||||
// Four are always out: leg, day, range, fill. The car's own three are folded away
|
||||
// until asked for, because nothing is stored on this vehicle yet.
|
||||
expect(inputs()).toHaveLength(4)
|
||||
openSpec()
|
||||
|
||||
// Leg, day, range, fill, battery, consumption, wear. The three at the end do not
|
||||
// exist until there is a kind of car to have them.
|
||||
expect(inputs()).toHaveLength(7)
|
||||
expect(screen.getByText('kWh')).toBeInTheDocument()
|
||||
expect(screen.getByText('kWh/100 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-010: a complete pair takes the range field over', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_battery_kwh: 58,
|
||||
roadtrip_kwh_per_100: 16,
|
||||
// Deliberately different from what the figures work out to: the point of
|
||||
// the row going read-only is that there is no longer a second answer.
|
||||
roadtrip_range_km: 500,
|
||||
distance_unit: 'metric',
|
||||
} as never,
|
||||
})
|
||||
open(vi.fn())
|
||||
|
||||
expect(screen.getByTestId('limit-derived')).toHaveTextContent('363')
|
||||
// Leg, day, battery, consumption, wear, fill — the range input is gone.
|
||||
expect(inputs()).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-011: gallons typed by an imperial reader are stored as litres', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_vehicle: 'combustion', distance_unit: 'imperial' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
// Leg, day, tank, consumption, range, fill.
|
||||
openSpec()
|
||||
const tank = screen.getByTestId('limit-tankLitres') as HTMLInputElement
|
||||
fireEvent.change(tank, { target: { value: '15' } })
|
||||
fireEvent.blur(tank)
|
||||
|
||||
// 15 US gallons, not 15 litres. Storing the number as typed would have made
|
||||
// every fuel warning fire at a quarter of the right distance.
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_tank_litres', 56.78)
|
||||
})
|
||||
it('FE-ROADTRIP-LIMITS-012: the range answers while the figure is still being typed', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_battery_kwh: 58,
|
||||
roadtrip_kwh_per_100: 16,
|
||||
distance_unit: 'metric',
|
||||
} as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
openSpec()
|
||||
// By name rather than by position: the rows are no longer at fixed indices now that
|
||||
// the car's figures live behind a disclosure.
|
||||
const consumption = screen.getByTestId('limit-kwhPer100') as HTMLInputElement
|
||||
fireEvent.change(consumption, { target: { value: '20' } })
|
||||
|
||||
// 58 kWh at 20 per hundred is 290 km, shown before anything is written: the gauge
|
||||
// is the argument that these figures ARE the range, and it only makes it if it
|
||||
// answers now rather than after the field is left.
|
||||
expect(screen.getAllByText('290').length).toBeGreaterThan(0)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* The two switches over the route line itself.
|
||||
*
|
||||
* Both are off by default and both change what the map draws, so what is asserted is
|
||||
* that each reports its own key and that neither is written until it is pressed.
|
||||
*/
|
||||
describe('the route line', () => {
|
||||
it('FE-ROADTRIP-LIMITS-013: joining the days up is off until it is asked for', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Connect the days' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(toggle)
|
||||
// Costs a routing request per join and changes every day's kilometres, so it is a
|
||||
// decision rather than a default.
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-014: a colour per day is its own switch', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'A colour per day' }))
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_day_colors', true)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-015: a switch that is on reports the way back off', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_connect_days: true, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Connect the days' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
|
||||
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
|
||||
} from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { convertDistance, formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { formatDurationShort, parseAvoid, serializeAvoid, type VehicleKind } from './roadtripModel'
|
||||
import { effectiveRangeKm, rangeFromSpec, showSpec, storeSpec, specUnit, type SpecKey, type VehicleSpec } from './vehicleRange'
|
||||
import RangeStrip from './RangeStrip'
|
||||
import { valhallaAvailable } from '../Map/valhallaRoute'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* The three numbers that decide when the rail speaks up about the driving.
|
||||
* Everything that decides when the rail speaks up about the driving.
|
||||
*
|
||||
* Behind a button rather than laid out in the column, because this is set once and then
|
||||
* read never: a permanent card costs three rows of a narrow sidebar for something most
|
||||
* read never: a permanent card costs rows of a narrow sidebar for something most
|
||||
* travellers touch on the first day of planning and leave alone after. The button carries
|
||||
* the current answer, so what is set stays visible without the form being.
|
||||
* the current answers, so what is set stays visible without the form being.
|
||||
*
|
||||
* All three are personal rather than instance configuration: how long somebody is willing
|
||||
* to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
|
||||
* They are plain per-user settings, which is why this needs no migration.
|
||||
* All of it is personal rather than instance configuration: how long somebody is willing
|
||||
* to sit behind a wheel, and what their car is, belong to the traveller. They are plain
|
||||
* per-user settings, which is why this needs no migration.
|
||||
*
|
||||
* Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
|
||||
* idea what car this is, and neither has TREK, so the range is a number the traveller
|
||||
* types. Furkot does exactly the same, for the same reason.
|
||||
* The dialog has two halves because the settings are two kinds. The left is the trip: how
|
||||
* long a day may be and what the road may not be. The right is the car, and it is the
|
||||
* half that earns the width — a range can be typed as one number or worked out from what
|
||||
* the vehicle is made of, and the gauge shows the second turning into the first as it is
|
||||
* typed. Nothing here asks the router about a vehicle; OSM has no idea what car this is,
|
||||
* and neither has TREK.
|
||||
*/
|
||||
|
||||
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
|
||||
@@ -31,12 +41,38 @@ function parseLimit(raw: string): number {
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
/**
|
||||
* One number in the form, or one number the form worked out.
|
||||
*
|
||||
* `derived` turns the row read-only and states where the figure came from. The row keeps
|
||||
* its shape either way, because a range that switched between an input and a line of text
|
||||
* would move every row under it the moment a consumption was typed.
|
||||
*/
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, testId, onDraft, onChange }: {
|
||||
icon: typeof Clock
|
||||
label: string
|
||||
suffix: string
|
||||
value: number | undefined
|
||||
placeholder: string
|
||||
step?: number
|
||||
derived?: string
|
||||
/**
|
||||
* A stable handle for the tests, because the rows are no longer at fixed indices.
|
||||
*
|
||||
* The vehicle figures now live behind a disclosure, so "the fourth number field" means
|
||||
* different things depending on whether it is open.
|
||||
*/
|
||||
testId?: string
|
||||
/**
|
||||
* What is in the field right now, on every keystroke, saved or not.
|
||||
*
|
||||
* Separate from `onChange` because the two answer different questions. `onChange` is
|
||||
* "this is the new setting", which must stay rare — it is an HTTP write. This one is
|
||||
* "this is what the traveller is looking at", which the gauge needs on every keystroke
|
||||
* or it would sit still until the field is left and the promise it makes, that these
|
||||
* figures are the range, would only pay out after the fact.
|
||||
*/
|
||||
onDraft?: (next: number) => void
|
||||
onChange: (next: number) => void
|
||||
}): React.ReactElement {
|
||||
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
|
||||
@@ -54,59 +90,279 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3">
|
||||
<label className="flex min-h-9 items-center gap-4">
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
// second commit is a no-op anyway, and going through blur made Enter
|
||||
// depend on focus handling instead of on the key.
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="w-7 text-caption text-content-faint">{suffix}</span>
|
||||
{/* Label and unit in one wrapping flow, not in two columns. The unit used to have a
|
||||
column of its own, sized per section, because "kWh/100 km" does not fit where
|
||||
"min" does — and that column was the reason every section had a right edge of
|
||||
its own. In the flow it simply wraps under a long label and costs nothing. */}
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-body text-content-secondary">{label}</span>
|
||||
{/* The unit as a chip rather than as more grey words after the label. "kWh/100 km"
|
||||
set as prose reads as part of the question; on a chip it reads as what the
|
||||
number beside it will be measured in, which is what it is. */}
|
||||
<span className="inline-flex items-center rounded-md bg-surface-tertiary px-1.5 py-0.5 text-caption leading-none text-content-muted">
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
{/* The one right edge of the whole dialog. Sized from the type rather than in fixed
|
||||
pixels, so it grows with the reader's text-size setting instead of clipping: six
|
||||
digits of tabular figures plus the field's own padding, and never narrower than
|
||||
a switch. Geometry, which the styleguide allows inline. */}
|
||||
<span className="flex shrink-0 justify-end" style={{ width: 'max(5.5rem, calc(6ch + 1.75rem))' }}>
|
||||
{derived ? (
|
||||
// A mark rather than a word: "computed" is eight characters in English and
|
||||
// fourteen in half the locales. The sentence lives in the title and in the
|
||||
// screen-reader text.
|
||||
<span className="flex items-center gap-1.5" title={derived} data-testid="limit-derived">
|
||||
<Sparkles size={12} className="shrink-0 text-accent-on" aria-hidden />
|
||||
<span className="text-body font-semibold tabular-nums text-content">{value ?? 0}</span>
|
||||
<span className="sr-only">{derived}</span>
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step={step ?? 1}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
aria-label={`${label}, ${suffix}`}
|
||||
data-testid={testId}
|
||||
onChange={e => { setDraft(e.target.value); onDraft?.(parseLimit(e.target.value)) }}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
// second commit is a no-op anyway, and going through blur made Enter
|
||||
// depend on focus handling instead of on the key.
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
// The spinners are the other half of the width problem: they eat a third of a
|
||||
// narrow field and nobody sets a driving limit by clicking an arrow.
|
||||
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One class the drive should leave out, with the switch that asks for it.
|
||||
*
|
||||
* Disabled rather than hidden when there is no engine that can answer: an operator who
|
||||
* pointed the instance at their own OSRM has no second engine, and a switch that flips
|
||||
* and changes nothing is worse than one that says why it cannot.
|
||||
*/
|
||||
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
|
||||
icon: typeof Coins
|
||||
label: string
|
||||
on: boolean
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
{disabled
|
||||
? <span className="text-caption text-content-faint">{'—'}</span>
|
||||
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One section of the dialog, as a panel of its own.
|
||||
*
|
||||
* The complaint the redesign started from was "untidy", and the cause was that four
|
||||
* sections sat in two columns with nothing between them but a heading: no edges, so the
|
||||
* eye had to work out where one ended, and two right edges, so nothing lined up across
|
||||
* the middle. A panel draws that boundary once and the rows inside inherit one rhythm.
|
||||
*
|
||||
* The closing note is part of the panel rather than a paragraph somebody remembers to add:
|
||||
* every one of these sections has a sentence that has to be said (driving time is driving
|
||||
* time only; avoidance is a weighting, not a ban), and left to each caller they drifted
|
||||
* apart in spacing and in colour.
|
||||
*/
|
||||
function Panel({ icon: Icon, title, note, children }: {
|
||||
icon: typeof Clock
|
||||
title: string
|
||||
note?: string
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
|
||||
<SectionTitle icon={Icon}>{title}</SectionTitle>
|
||||
{children}
|
||||
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The name of one section, inside its panel. */
|
||||
function SectionTitle({ icon: Icon, children }: {
|
||||
icon: typeof Clock
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-caption font-semibold uppercase tracking-wide text-content-faint">
|
||||
<Icon size={13} aria-hidden />
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */
|
||||
const VEHICLES: { key: VehicleKind | null; labelKey: string; Icon: typeof Car }[] = [
|
||||
{ key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car },
|
||||
{ key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel },
|
||||
{ key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap },
|
||||
]
|
||||
|
||||
/**
|
||||
* What each kind of car is made of, and where each figure is kept.
|
||||
*
|
||||
* Two rows for petrol and three for electric, because that is the difference that
|
||||
* matters: a tank does not lose capacity to the years and a battery does. Everything else
|
||||
* ABRP asks for — plug type, reference speed, relative speed, drive style — shapes a
|
||||
* consumption PREDICTION. This dialog does not predict one; it divides a capacity by a
|
||||
* consumption the traveller states, so those fields would add typing without moving a
|
||||
* single kilometre of the answer.
|
||||
*/
|
||||
const SPEC_ROWS: Record<VehicleKind, { key: SpecKey; setting: string; Icon: typeof Fuel; labelKey: string; step: number }[]> = {
|
||||
combustion: [
|
||||
{ key: 'tankLitres', setting: 'roadtrip_tank_litres', Icon: Fuel, labelKey: 'roadtrip.limit.tankLabel', step: 1 },
|
||||
{ key: 'litresPer100', setting: 'roadtrip_litres_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
|
||||
],
|
||||
electric: [
|
||||
{ key: 'batteryKwh', setting: 'roadtrip_battery_kwh', Icon: BatteryFull, labelKey: 'roadtrip.limit.batteryLabel', step: 1 },
|
||||
{ key: 'kwhPer100', setting: 'roadtrip_kwh_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 },
|
||||
{ key: 'degradationPercent', setting: 'roadtrip_battery_degradation', Icon: BatteryWarning, labelKey: 'roadtrip.limit.wearLabel', step: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings every one of the car's own figures is stored under.
|
||||
*
|
||||
* Read once, to decide whether the disclosure below the range starts open: a stored
|
||||
* figure means the range row is derived, and its cause has to be in sight.
|
||||
*/
|
||||
const SPEC_SETTINGS = ['roadtrip_tank_litres', 'roadtrip_litres_per_100', 'roadtrip_battery_kwh', 'roadtrip_kwh_per_100', 'roadtrip_battery_degradation'] as const
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
/** Persists one setting. Absent leaves the dialog read-only. */
|
||||
onSave?: (key: string, value: number) => void
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
*
|
||||
* A string as well as a number since the avoidance is stored as a comma list: one
|
||||
* decision with three parts, which goes to the router as one request either way.
|
||||
*/
|
||||
onSave?: (key: string, value: number | string | boolean) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const [open, setOpen] = useState(false)
|
||||
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
const imperial = distanceUnit === 'imperial'
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
|
||||
// an unknown word here would become a costing option the router does not have.
|
||||
const vehicle = settings.roadtrip_vehicle ?? ''
|
||||
const vehicleKind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
const saved: VehicleSpec = {
|
||||
tankLitres: settings.roadtrip_tank_litres,
|
||||
litresPer100: settings.roadtrip_litres_per_100,
|
||||
batteryKwh: settings.roadtrip_battery_kwh,
|
||||
kwhPer100: settings.roadtrip_kwh_per_100,
|
||||
degradationPercent: settings.roadtrip_battery_degradation,
|
||||
}
|
||||
/**
|
||||
* The one field being typed in, ahead of the write that will save it.
|
||||
*
|
||||
* One slot rather than a copy of the form, because only one field can be typed in at a
|
||||
* time, and a second copy of the settings is exactly the hand-mirrored state this
|
||||
* codebase refuses everywhere else. It exists so the gauge answers while the traveller
|
||||
* is still typing: the figures below it are only convincingly "the range" if changing
|
||||
* one moves the bar, and a settings PUT per keystroke is not on offer.
|
||||
*
|
||||
* The value arrives in whatever unit the field is labelled with, so it goes back
|
||||
* through the same conversion the save would have used.
|
||||
*/
|
||||
const [preview, setPreview] = useState<{ key: SpecKey | 'range' | 'fill'; value: number } | null>(null)
|
||||
/**
|
||||
* Whether the car's own figures are unfolded.
|
||||
*
|
||||
* Open from the start the moment any of them is stored, because then the range row is
|
||||
* read-only and folding its cause away would leave a field nobody can edit with no
|
||||
* visible reason. Read once at mount: re-deriving it would slam the panel shut under
|
||||
* the hand of somebody clearing the last field.
|
||||
*/
|
||||
const [specOpen, setSpecOpen] = useState(() =>
|
||||
SPEC_SETTINGS.some(k => typeof (settings as unknown as Record<string, unknown>)[k] === 'number'))
|
||||
const spec: VehicleSpec = preview && preview.key !== 'range' && preview.key !== 'fill'
|
||||
? { ...saved, [preview.key]: storeSpec(preview.key, preview.value, imperial) }
|
||||
: saved
|
||||
const typedKm = preview?.key === 'range'
|
||||
? (imperial ? preview.value / 0.621371 : preview.value)
|
||||
: rangeKm
|
||||
const shownFill = preview?.key === 'fill' ? preview.value : settings.roadtrip_fill_percent ?? null
|
||||
|
||||
// What the parts say, and what the trip will actually plan with. The second is the
|
||||
// first when there is one, which is the rule effectiveRangeKm states once for
|
||||
// everybody who needs it.
|
||||
const computedKm = rangeFromSpec(vehicleKind, spec)
|
||||
const planningKm = effectiveRangeKm(vehicleKind, spec, typedKm)
|
||||
|
||||
const avoiding = parseAvoid(settings.roadtrip_avoid)
|
||||
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
|
||||
// car profile is built without excludable classes on every public host.
|
||||
const canAvoid = valhallaAvailable()
|
||||
const toggleAvoid = (cls: RouteAvoidClass) => {
|
||||
const next = avoiding.includes(cls) ? avoiding.filter(c => c !== cls) : [...avoiding, cls]
|
||||
onSave?.('roadtrip_avoid', serializeAvoid(next))
|
||||
}
|
||||
|
||||
const AVOID_ROWS: { cls: RouteAvoidClass; icon: typeof Coins; label: string }[] = [
|
||||
{ cls: 'toll', icon: Coins, label: t('roadtrip.avoid.toll') },
|
||||
{ cls: 'motorway', icon: Signpost, label: t('roadtrip.avoid.motorway') },
|
||||
{ cls: 'ferry', icon: Ship, label: t('roadtrip.avoid.ferry') },
|
||||
]
|
||||
|
||||
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after.
|
||||
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
|
||||
// third too early for ever after. The same rule runs through the vehicle's own figures,
|
||||
// where showSpec and storeSpec do it per field.
|
||||
const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined
|
||||
const setRange = (shown: number) => {
|
||||
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
|
||||
const km = imperial ? shown / 0.621371 : shown
|
||||
onSave?.('roadtrip_range_km', Math.round(km))
|
||||
}
|
||||
|
||||
// What is set, on the button itself, so the form does not have to be open to read it.
|
||||
// Each one keeps its own icon, because three numbers in a row say nothing about which
|
||||
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
|
||||
// an imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// Each one keeps its own icon, because numbers in a row say nothing about which is
|
||||
// which: a clock, a calendar and a pump do. Distances go through formatDistance, so an
|
||||
// imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// stored stays kilometres either way.
|
||||
const badges = [
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
|
||||
// The planning range, not the typed one: when the vehicle's figures win, the badge
|
||||
// has to say what the warnings will actually use.
|
||||
planningKm ? { key: 'range', Icon: vehicleKind === 'electric' ? Zap : Fuel, text: formatDistance(planningKm, distanceUnit) } : null,
|
||||
// Only worth a badge when it is NOT a full tank, which is the case that changes the
|
||||
// arithmetic. "100 %" on the trigger would be a badge for the default.
|
||||
settings.roadtrip_fill_percent && settings.roadtrip_fill_percent < 100
|
||||
? { key: 'fill', Icon: BatteryCharging, text: `${settings.roadtrip_fill_percent} %` }
|
||||
: null,
|
||||
// The avoidance rides here too, and as ONE badge rather than three: it is a single
|
||||
// decision, the three icons already say which parts of it are on, and a fourth,
|
||||
// fifth and sixth badge would wrap the row onto a second line in a narrow rail.
|
||||
avoiding.length && canAvoid
|
||||
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
|
||||
: null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
|
||||
return (
|
||||
@@ -120,7 +376,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* No icon tile on the trigger: the three badges below already carry a clock, a
|
||||
{/* No icon tile on the trigger: the badges below already carry a clock, a
|
||||
calendar and a pump, and a fourth mark in front of the title only ate the width
|
||||
they need to sit on one line. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
@@ -156,43 +412,213 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
|
||||
<Modal isOpen onClose={() => { setOpen(false); setPreview(null) }} size="4xl" title={
|
||||
<span className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
label={t('roadtrip.limit.legLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={legMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={CalendarClock}
|
||||
label={t('roadtrip.limit.dayLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={dayMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={Fuel}
|
||||
label={t('roadtrip.limit.rangeLabel')}
|
||||
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Said out loud rather than left to be discovered: the day figure this is
|
||||
measured against is driving time only. It does not include how long anyone
|
||||
stands still, and it does not include the leg to the hotel, because the
|
||||
rail does not model one. A limit called "travel time per day" over that
|
||||
number would be a lie that looks tidy. */}
|
||||
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
|
||||
{/* Two columns, kept. One column reads more tidily but turns the dialog into
|
||||
something that has to be scrolled, and a setting you cannot see while you
|
||||
change another is worse than a seam down the middle.
|
||||
|
||||
What answers the seam is not the count of columns but what is in them: each
|
||||
section is now a panel of its own with a visible edge, every row inside it
|
||||
ends on the same right edge, and the panels are stacked to one rhythm. The
|
||||
left column is the trip, the right one is the car. */}
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
label={t('roadtrip.limit.legLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={legMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId="limit-legMinutes"
|
||||
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={CalendarClock}
|
||||
label={t('roadtrip.limit.dayLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={dayMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId="limit-dayMinutes"
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
icon={Signpost}
|
||||
title={t('roadtrip.avoid.section')}
|
||||
note={canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
|
||||
>
|
||||
{AVOID_ROWS.map(({ cls, icon, label }) => (
|
||||
<AvoidRow
|
||||
key={cls}
|
||||
icon={icon}
|
||||
label={label}
|
||||
on={avoiding.includes(cls)}
|
||||
disabled={!canAvoid}
|
||||
onToggle={() => toggleAvoid(cls)}
|
||||
/>
|
||||
))}
|
||||
</Panel>
|
||||
|
||||
<Panel icon={Route} title={t('roadtrip.line.section')} note={t('roadtrip.line.hint')}>
|
||||
{/* The road between one day's last stop and the next day's first is real
|
||||
driving that the rail has never drawn: days are routed one at a time,
|
||||
so that gap was never asked for. Off by default because it costs a
|
||||
routing request per join and changes every day's kilometres. */}
|
||||
<AvoidRow
|
||||
icon={Link2}
|
||||
label={t('roadtrip.line.connect')}
|
||||
on={!!settings.roadtrip_connect_days}
|
||||
disabled={false}
|
||||
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
|
||||
/>
|
||||
{/* Which matters most once the line IS continuous: end to end it is one
|
||||
stroke, and a colour per day is what puts the days back into it. */}
|
||||
<AvoidRow
|
||||
icon={Palette}
|
||||
label={t('roadtrip.line.dayColors')}
|
||||
on={!!settings.roadtrip_day_colors}
|
||||
disabled={false}
|
||||
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
icon={Car}
|
||||
title={t('roadtrip.limit.sectionVehicle')}
|
||||
note={vehicleKind ? t('roadtrip.limit.specHint') : t('roadtrip.limit.vehicleHint')}
|
||||
>
|
||||
{/* First, because it decides what everything under it means: with no
|
||||
vehicle named, a petrol station and a charger both fill the tank, which
|
||||
is arithmetic that is wrong for everybody who drives just one of them.
|
||||
|
||||
Radios in a segmented shell rather than a dropdown: three options that
|
||||
change the meaning of everything below them should be readable without
|
||||
being opened, and the arrow keys, the names and the roles then come
|
||||
from the browser instead of from a rebuilt listbox. */}
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="sr-only">{t('roadtrip.limit.vehicleLabel')}</legend>
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl border border-edge bg-surface-tertiary p-1">
|
||||
{VEHICLES.map(({ key, labelKey, Icon }) => (
|
||||
<label key={key ?? 'unset'} className="min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="roadtrip-vehicle"
|
||||
className="peer sr-only"
|
||||
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
|
||||
onChange={() => {
|
||||
// Never writes the car's figures away: switching kind leaves
|
||||
// both sets stored, `rangeFromSpec` simply returns null for
|
||||
// the other one, and the typed range comes back as an editable
|
||||
// field. Deleting the other kind's values here would lose data
|
||||
// on a mis-click.
|
||||
setPreview(null)
|
||||
onSave?.('roadtrip_vehicle', key ?? '')
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
|
||||
<Icon size={14} className="shrink-0" aria-hidden />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<RangeStrip
|
||||
rangeKm={planningKm}
|
||||
fillPercent={shownFill}
|
||||
wearPercent={vehicleKind === 'electric' ? spec.degradationPercent ?? null : null}
|
||||
unit={distanceUnit}
|
||||
electric={vehicleKind === 'electric'}
|
||||
/>
|
||||
|
||||
{/* Read-only once the parts add up, because then it IS the parts. Leaving
|
||||
it editable would offer a second answer to a question that already has
|
||||
one, and the traveller would have no way of telling which the warnings
|
||||
were using. Clearing a field above hands the row straight back. */}
|
||||
<LimitRow
|
||||
icon={Route}
|
||||
// "on one tank" is the wrong noun for half the travellers who set this.
|
||||
label={t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel')}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
|
||||
testId="limit-range"
|
||||
onDraft={v => setPreview({ key: 'range', value: v })}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Under the range, because it is a fraction OF it. Nobody charges to
|
||||
100 % on the road: the last fifth takes as long as the first four, so a
|
||||
stop counted as a full tank overstates everything after it by that
|
||||
fifth. */}
|
||||
<LimitRow
|
||||
icon={BatteryCharging}
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
suffix="%"
|
||||
value={settings.roadtrip_fill_percent}
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
testId="limit-fill"
|
||||
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
|
||||
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
|
||||
/>
|
||||
|
||||
{/* The car's own figures, folded away. Nothing here is required — somebody
|
||||
who knows their range types it above and never opens this — but it
|
||||
starts open the moment any of them is stored, because otherwise the
|
||||
range row is read-only with its cause out of sight. */}
|
||||
{vehicleKind ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSpecOpen(o => !o); setPreview(null) }}
|
||||
aria-expanded={specOpen}
|
||||
aria-controls="roadtrip-spec"
|
||||
className="-mx-1 flex items-center gap-1.5 self-start rounded-lg px-1 py-0.5 text-caption font-semibold text-accent-on transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={`shrink-0 transition-transform ${specOpen ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t('roadtrip.limit.specToggle')}
|
||||
</button>
|
||||
{/* `hidden` rather than unmounting: it really leaves the tab order,
|
||||
and a field that kept a draft while folded away would be a draft
|
||||
nobody can see. */}
|
||||
{/* The display class only while it is open. `[hidden]` sets
|
||||
`display: none` in the preflight, but a `flex` class beats it on
|
||||
specificity — so the attribute flipped, the semantics were right,
|
||||
and the panel stayed on screen. */}
|
||||
<div id="roadtrip-spec" hidden={!specOpen} className={specOpen ? 'flex flex-col gap-3' : ''}>
|
||||
{SPEC_ROWS[vehicleKind].map(({ key, setting, Icon, labelKey, step }) => (
|
||||
<LimitRow
|
||||
key={key}
|
||||
icon={Icon}
|
||||
label={t(labelKey)}
|
||||
suffix={specUnit(key, imperial)}
|
||||
step={step}
|
||||
value={showSpec(key, saved[key], imperial)}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
testId={`limit-${key}`}
|
||||
onDraft={v => setPreview({ key, value: v })}
|
||||
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripSidebar from './RoadtripSidebar'
|
||||
import RoadtripModeSwitch from './RoadtripModeSwitch'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { DryPoint } from './roadtripModel'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
@@ -12,6 +15,11 @@ const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</Trans
|
||||
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
|
||||
return {
|
||||
placeId: over.assignmentId * 10,
|
||||
// Filled in by `day()` from the card it is put on, unless the case under test says
|
||||
// otherwise: a stop names the day it is STORED on, which after a night drive is not
|
||||
// the card it is drawn on. -1 is "not stated", never a real id.
|
||||
ownerDayId: -1,
|
||||
ownerIndex: -1,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
@@ -31,12 +39,12 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
const dayId = over.dayId ?? 1
|
||||
return {
|
||||
dayId: 1,
|
||||
dayId,
|
||||
dayNumber: 1,
|
||||
date: null,
|
||||
title: null,
|
||||
stops,
|
||||
legs: [leg()],
|
||||
legVias: [], driveWarnings: [], dayWarning: null,
|
||||
schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
|
||||
@@ -44,6 +52,13 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
distance: 100000,
|
||||
duration: 3600,
|
||||
...over,
|
||||
// After the spread, because `over` carries the raw stops this was built from: the
|
||||
// same pair the hook fills in, for every stop that did not state its own.
|
||||
stops: stops.map((s, i) => ({
|
||||
...s,
|
||||
ownerDayId: s.ownerDayId === -1 ? dayId : s.ownerDayId,
|
||||
ownerIndex: s.ownerIndex === -1 ? i : s.ownerIndex,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +66,7 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
return {
|
||||
days: [day()],
|
||||
lines: [],
|
||||
lineDays: [],
|
||||
segments: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
@@ -168,11 +184,14 @@ describe('RoadtripSidebar', () => {
|
||||
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
|
||||
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive shows the empty state, not a zeroed rail', () => {
|
||||
// Nothing to total up, so no "0 km" standing over an empty list. The mascot
|
||||
// is the shape every other empty state in TREK uses.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
|
||||
expect(screen.getByText('No route yet')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/0 km/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
|
||||
@@ -198,11 +217,15 @@ describe('RoadtripSidebar', () => {
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
// Said twice on purpose: once as the break in the chain, once on the arrival after
|
||||
// it, where "01:30" would otherwise read as tonight.
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
// The day marker rides on the arrival that crossed midnight, where "01:30" would
|
||||
// otherwise read as tonight.
|
||||
// The crossing is no longer a band in the chain: a drive past midnight hands its
|
||||
// stops to the next day's card, and the block around them there is what says so
|
||||
// (`nightSpill.ts`). What is left on a day that could not hand them on — the last day
|
||||
// of a trip, or one whose arrivals are supplied directly as they are here — is the
|
||||
// marker on the arrival, where "01:30" would otherwise read as tonight.
|
||||
// One mention, not two: the marker on the arrival, spelled out for a screen reader.
|
||||
// The band that used to repeat it in the chain is gone — a drive past midnight hands
|
||||
// its stops to the next day's card now, and the block around them there says it.
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(1)
|
||||
expect(screen.getByText('01:30').textContent).toContain('+1')
|
||||
})
|
||||
|
||||
@@ -221,7 +244,9 @@ describe('RoadtripSidebar', () => {
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
// Both findings still reach the stop; only the band that used to repeat the crossing
|
||||
// is gone. Reading just the first warning used to drop the late flag entirely.
|
||||
expect(screen.getByText('01:30').textContent).toContain('+1')
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -299,6 +324,23 @@ describe('RoadtripSidebar', () => {
|
||||
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
|
||||
// The schedule restarts its chain at any pinned time, whatever kind of stop carries
|
||||
// it, so it computes the finding for a fuel halt too. The rail used to hand `late`
|
||||
// only to the numbered branch and throw the pause's own away.
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
|
||||
]
|
||||
const schedule = {
|
||||
entries: stops.map(() => ({ arrival: '09:00', departure: '09:15', anchored: true, dayOffset: 0 })),
|
||||
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} />)
|
||||
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
@@ -537,6 +579,443 @@ describe('RoadtripSidebar', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
|
||||
})
|
||||
|
||||
/**
|
||||
* The block a night drive leaves on the next morning's card.
|
||||
*
|
||||
* The stops are stored on the day they set off from and drawn on the day they are
|
||||
* reached, so what is asserted here is that the block says where they came from and
|
||||
* that the drive through the night is drawn with them. See `nightSpill.ts`.
|
||||
*/
|
||||
describe('a stretch driven onto this day through the night', () => {
|
||||
const spilled = () => day({
|
||||
dayNumber: 2,
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
|
||||
stop({ assignmentId: 2, name: 'Wittenberg' }),
|
||||
],
|
||||
spills: [{
|
||||
at: 0,
|
||||
count: 1,
|
||||
fromDayNumber: 1,
|
||||
departure: '22:30',
|
||||
leg: leg({ distance: 253000, duration: 10380 }),
|
||||
line: [[53, 10], [52, 11]],
|
||||
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
|
||||
}],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-031: names the day the stretch came from', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
|
||||
expect(screen.getByText('From day 1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-032: draws the night drive, so the kilometres reach the stops', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [spilled()] })} />)
|
||||
// Without it the card would gain a stop and none of the driving that reaches it.
|
||||
expect(screen.getByText('253 km in 2 h 53 min')).toBeTruthy()
|
||||
expect(screen.getByText('leaves 22:30')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-030: a drag names the day the stop is STORED on, not the card', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [spilled()] })}
|
||||
onReorderStop={onReorderStop}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>)
|
||||
|
||||
// Dropping the borrowed stop onto its neighbour is a reorder inside day 9 — the day
|
||||
// the server knows it by — even though both are drawn under day 2.
|
||||
// The draggable rows only: the block around the borrowed stretch is a list item
|
||||
// too, and it contains the text of every stop inside it.
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[0], { dataTransfer })
|
||||
fireEvent.dragOver(rows[1], { dataTransfer })
|
||||
fireEvent.drop(rows[1], { dataTransfer })
|
||||
// Dragged from day 9 index 3 — where the server has it — onto day 1 index 1, the
|
||||
// stored position of the row it was dropped on. Neither number is the card's.
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(9, 1, 1, 1)
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-033: an ordinary day draws no block at all', () => {
|
||||
wrap(<RoadtripSidebar routes={routes()} />)
|
||||
expect(screen.queryByText(/From day/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The band where the fuel actually ends, and the one search offered from it.
|
||||
*
|
||||
* It hangs off the leg rather than off a stop, because those are two different places:
|
||||
* the range warning marks where somebody finds out, and that can be a long way past the
|
||||
* point the tank ran dry. A station offered at the warning is one the car cannot reach.
|
||||
*/
|
||||
describe('the leg the tank runs out on', () => {
|
||||
const dry = (over: Partial<DryPoint> = {}): DryPoint & { lat: number; lng: number } => ({
|
||||
legIndex: 0,
|
||||
intoLegKm: 182,
|
||||
drivenMeters: 182000,
|
||||
// The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
|
||||
// traveller's own setting, so a band printing it would read the same figure on
|
||||
// every leg of the day.
|
||||
sinceKm: 600,
|
||||
lat: 52.4,
|
||||
lng: 10.2,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Idle unless a case says which part of the search it is standing in. */
|
||||
const search = (over: Partial<RefuelSearch> = {}): RefuelSearch => ({
|
||||
openFor: null,
|
||||
loading: false,
|
||||
outcome: null,
|
||||
results: [],
|
||||
offered: [],
|
||||
ask: vi.fn(),
|
||||
close: vi.fn(),
|
||||
...over,
|
||||
})
|
||||
|
||||
const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
|
||||
osm_id: `osm-${name}`,
|
||||
name,
|
||||
lat: 52.4,
|
||||
lng: 10.1,
|
||||
category: 'fuel',
|
||||
poi_type: 'fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
alongKm: 170,
|
||||
offRouteKm,
|
||||
spareKm,
|
||||
})
|
||||
|
||||
const empties = dry({ legIndex: 1 })
|
||||
// Three stops, so the leg that empties has one it is not on to be absent from.
|
||||
const thirsty = (): RoadtripDay => day({
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Hannover' }),
|
||||
stop({ assignmentId: 3, name: 'Kassel' }),
|
||||
],
|
||||
legs: [leg(), leg()],
|
||||
dryPoints: [empties],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-035: the band sits on the leg the fuel ends on, and says how far into it', () => {
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [thirsty()], totalStops: 3 })} refuel={search()} />,
|
||||
)
|
||||
|
||||
// The second row owns the second leg. Drawn on every row instead, the rail would
|
||||
// offer a fill-up on a stretch the car drives with a full tank.
|
||||
const rows = container.querySelectorAll('li')
|
||||
expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
|
||||
expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
|
||||
// Distance into the leg, which is where a drive band keeps its figures. The 600 is
|
||||
// the setting that was crossed and belongs to no place on the map.
|
||||
expect(screen.getByText('after 182 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/600 km/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-036: the low-fuel lamp is itself the button that goes looking', () => {
|
||||
const onAskRefuel = vi.fn()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search()}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Not a lamp with a magnifier beside it, which says the same thing twice: the lamp
|
||||
// reports the empty tank and pressing it is what does something about it, so the
|
||||
// lamp has to be inside the button rather than next to one.
|
||||
const ask = screen.getByRole('button', { name: 'Find fuel' })
|
||||
expect(ask.querySelector('svg.lucide-fuel')).toBeInTheDocument()
|
||||
expect(within(ask).queryByRole('button')).toBeNull()
|
||||
|
||||
fireEvent.click(ask)
|
||||
// The dry point travels with the ask: the search is a circle pulled back from that
|
||||
// coordinate, and the day alone does not say where on it to look.
|
||||
expect(onAskRefuel).toHaveBeenCalledWith(1, empties)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-037: a band inside a borrowed stretch is filed under the card it is drawn on', () => {
|
||||
const onAskRefuel = vi.fn()
|
||||
const overnight = dry({ legIndex: 0 })
|
||||
const card = day({
|
||||
dayNumber: 2,
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
|
||||
stop({ assignmentId: 2, name: 'Wittenberg' }),
|
||||
],
|
||||
dryPoints: [overnight],
|
||||
spills: [{
|
||||
at: 0,
|
||||
count: 1,
|
||||
fromDayNumber: 1,
|
||||
departure: '22:30',
|
||||
leg: leg(),
|
||||
line: [[53, 10], [52, 11]],
|
||||
fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
|
||||
}],
|
||||
})
|
||||
const { unmount } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [card] })}
|
||||
refuel={search({ openFor: '1:0', loading: true })}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Everything that WRITES names the day a stop is stored on, day 9 here. This is the
|
||||
// exception and it has to be: the open search is filed under the card day and the
|
||||
// leg, so a band asking as day 9 would have its answers arrive under a key no band
|
||||
// is watching and would never open. That it opened at all is the assertion.
|
||||
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [card] })}
|
||||
refuel={search()}
|
||||
onAskRefuel={onAskRefuel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Find fuel' }))
|
||||
expect(onAskRefuel).toHaveBeenCalledWith(1, overnight)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-038: while the request is in flight the lamp steps aside for the way out', () => {
|
||||
const close = vi.fn()
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', loading: true, close })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
|
||||
// Gone, not merely quieter. Every press is a real request against a shared service,
|
||||
// and a lamp still standing there invites a second one nobody asked for.
|
||||
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-039: an answer carries the detour and what is left in the tank as two figures', () => {
|
||||
const onAcceptRefuel = vi.fn()
|
||||
const shell = pump('Shell Hannover', 1.4, 48.6)
|
||||
wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', outcome: 'found', results: [shell] })}
|
||||
onAcceptRefuel={onAcceptRefuel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Two facts about two different things: the detour is what the stop costs and what
|
||||
// the list is sorted by, the spare is what is left when the car draws level. Joined
|
||||
// into one line with a separator, neither exact lookup finds anything.
|
||||
expect(screen.getByText('1.4 km')).toBeInTheDocument()
|
||||
expect(screen.getByText('49 km')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Shell Hannover as a fuel stop' }))
|
||||
// With the dry point, because the caller has no other way of knowing which leg the
|
||||
// accepted stop belongs on.
|
||||
expect(onAcceptRefuel).toHaveBeenCalledWith(1, shell, empties)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-040: a search that came back with nothing says which nothing it was', () => {
|
||||
const answered = (outcome: RefuelOutcome) => (
|
||||
<TranslationProvider>
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [thirsty()], totalStops: 3 })}
|
||||
refuel={search({ openFor: '1:1', outcome })}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
)
|
||||
const { rerender } = render(answered('none'))
|
||||
|
||||
// Only the first of these is a statement about the road. Telling somebody there is
|
||||
// nothing on a stretch that was never fully checked, or never checked at all, is
|
||||
// worse than saying nothing.
|
||||
expect(screen.getByText(/Nothing found/)).toBeInTheDocument()
|
||||
rerender(answered('incomplete'))
|
||||
expect(screen.getByText(/cut short/)).toBeInTheDocument()
|
||||
rerender(answered('failed'))
|
||||
expect(screen.getByText(/did not answer/)).toBeInTheDocument()
|
||||
|
||||
// And a way to ask again rather than a dead end: the place search is a shared
|
||||
// service that does time out, and no answer with no retry reads as broken.
|
||||
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-041: the stop stops repeating what the band already says better', () => {
|
||||
// The warning marks the stop somebody finds out at and counts the whole tank; the
|
||||
// band sits where the fuel ends and offers a way out. Both at once prints the answer
|
||||
// above the problem.
|
||||
const warned = (): RoadtripDay => ({
|
||||
...thirsty(),
|
||||
driveWarnings: [{ index: 2, code: 'range', sinceKm: 640 }],
|
||||
})
|
||||
const { unmount } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} refuel={search()} />,
|
||||
)
|
||||
expect(screen.queryByText('640 km')).toBeNull()
|
||||
|
||||
unmount()
|
||||
// With no search to run there is nothing better, so the finding is all there is and
|
||||
// has to stay.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} />)
|
||||
expect(screen.getByText('640 km')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Folding a day down to its header.
|
||||
*
|
||||
* The whole header is the control and the hover is the only sign of it: a chevron or a
|
||||
* fold label sitting among the day's own facts reads as a fourth fact about the day
|
||||
* rather than as something to press. Nothing on screen says which way it stands, so the
|
||||
* header has to say it to a screen reader.
|
||||
*/
|
||||
describe('a day folded down to its header', () => {
|
||||
it('FE-ROADTRIP-SIDEBAR-042: the header is the control, and it folds by id rather than by number', () => {
|
||||
const onToggleDay = vi.fn()
|
||||
// Id and number deliberately apart. The card is titled by its number and folded by
|
||||
// its id, and on a trip whose days were not created in order those two differ —
|
||||
// mixing them up folds a card the reader did not click.
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ dayId: 42 })] })} onToggleDay={onToggleDay} />)
|
||||
|
||||
const header = screen.getByRole('button', { name: /Day 1/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(onToggleDay).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-043: folded, the stops are gone and the header is what is left', () => {
|
||||
// Folded by id, again with a card whose id is not its number: a set read as numbers
|
||||
// would fold the card titled "Day 42" here, or nothing at all.
|
||||
wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [day({ dayId: 42 }), day({ dayId: 2, dayNumber: 2 })], totalStops: 4 })}
|
||||
onToggleDay={vi.fn()}
|
||||
collapsedDayIds={new Set([42])}
|
||||
/>)
|
||||
|
||||
const folded = screen.getByRole('button', { name: /Day 1/ })
|
||||
expect(folded).toHaveAttribute('aria-expanded', 'false')
|
||||
// The card keeps its head: a day put away is still one the reader scrolls past, and
|
||||
// its own figures are how it is found again.
|
||||
expect(within(folded).getByText('2 stops')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Hamburg')[0]).not.toBeVisible()
|
||||
// One card at a time. Folding is per day, and the id is what decides which.
|
||||
expect(screen.getByRole('button', { name: /Day 2/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getAllByText('Berlin')[1]).toBeVisible()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-044: the header answers the keys that mean press, and no others', () => {
|
||||
const onToggleDay = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onToggleDay={onToggleDay} />)
|
||||
const header = screen.getByRole('button', { name: /Day 1/ })
|
||||
|
||||
// A header wearing a button role gets none of a real button's keyboard behaviour, so
|
||||
// it answers both keys itself. Space has to be swallowed as well as answered, or the
|
||||
// rail scrolls a page down behind the day that was just put away. fireEvent reports
|
||||
// a cancelled event as false.
|
||||
fireEvent.keyDown(header, { key: 'Enter' })
|
||||
expect(fireEvent.keyDown(header, { key: ' ' })).toBe(false)
|
||||
expect(onToggleDay).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.keyDown(header, { key: 'ArrowDown' })
|
||||
expect(onToggleDay).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-045: a rail with nothing to fold offers no control at all', () => {
|
||||
wrap(<RoadtripSidebar routes={routes()} />)
|
||||
|
||||
// A viewer gets a heading. A control that does nothing when pressed is worse than
|
||||
// one that is not there.
|
||||
expect(screen.queryByRole('button', { name: /Day 1/ })).toBeNull()
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeVisible()
|
||||
expect(screen.getByText('Hamburg')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* How full one stop fills the tank.
|
||||
*
|
||||
* A property of the stop rather than of the traveller: the motorway rapid charger is
|
||||
* worth 80 % because the last fifth costs as long again, the one at the hotel is worth
|
||||
* all of it. The badge is the only way into that figure anywhere in TREK.
|
||||
*/
|
||||
describe('the fill badge on a stop that puts fuel back', () => {
|
||||
const refuelling = (fillPercent?: number | null): RoadtripDay => day({
|
||||
stops: [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', fillPercent }),
|
||||
],
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-046: the badge is the way into a figure that is not there yet', () => {
|
||||
const onSelectStop = vi.fn()
|
||||
const onSetStopFill = vi.fn()
|
||||
wrap(<RoadtripSidebar
|
||||
routes={routes({ days: [refuelling()] })}
|
||||
onSelectStop={onSelectStop}
|
||||
onSetStopFill={onSetStopFill}
|
||||
/>)
|
||||
|
||||
// The plus stands in the slot the number will occupy: a badge that hid itself until
|
||||
// a figure existed could never be used to make one.
|
||||
fireEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
|
||||
// The whole row is a button too. A click that reached it would select the stop and
|
||||
// move the map out from under the panel that just opened.
|
||||
expect(onSelectStop).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '80' }))
|
||||
expect(onSetStopFill).toHaveBeenCalledWith(20, 80)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-047: the badge opens from the keyboard and swallows the key', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [refuelling()] })} onSetStopFill={vi.fn()} />)
|
||||
const badge = screen.getByRole('button', { name: '+' })
|
||||
|
||||
// The same span-with-a-role bargain as the header, one row further in: unanswered,
|
||||
// the only way to the figure is a mouse.
|
||||
fireEvent.keyDown(badge, { key: 'ArrowDown' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
|
||||
expect(fireEvent.keyDown(badge, { key: 'Enter' })).toBe(false)
|
||||
expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-048: only a stop that fills up carries the badge, and its own figure reads as one', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [refuelling(100)] })} onSetStopFill={vi.fn()} />)
|
||||
|
||||
// An explicit 100 is a decision, not an absent figure: on a trip whose default is 80
|
||||
// it says this one goes right up, and folding it into the invitation would leave the
|
||||
// traveller reading 80 on a stop that fills to 100.
|
||||
expect(screen.getAllByRole('button', { name: '100 %' })).toHaveLength(1)
|
||||
// Hamburg has none at all. A place the trip is for fills no tank, so a fill figure
|
||||
// there is a control for a decision nobody makes.
|
||||
expect(screen.queryByText('+')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripModeSwitch', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,4 +140,21 @@ describe('RoadtripStayModal', () => {
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-013: a stay running past midnight says which day it leaves on', () => {
|
||||
// The clock wraps modulo 24 h, so without the carry this reads "01:00" for a
|
||||
// departure the chain places on the next day — the rail says +1 beside it and
|
||||
// the dialog used to say nothing.
|
||||
open({ minutes: 180, arrival: '22:00' })
|
||||
|
||||
expect(screen.getByText('01:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-014: a stay inside one day carries no day marker', () => {
|
||||
open({ minutes: 60, arrival: '12:00' })
|
||||
|
||||
expect(screen.getByText('13:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,6 +81,11 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
||||
return {
|
||||
arrive: formatClockTime(formatClock(arrival), is12h),
|
||||
leave: formatClockTime(formatClock(arrival + minutes), is12h),
|
||||
// `formatClock` wraps modulo 24 h, so a stay that runs past midnight reads as a
|
||||
// small number again. The rail carries the day it belongs to along with the time;
|
||||
// without the same carry here the dialog would quietly say "leave 01:00" for a
|
||||
// stop the chain places on the next day.
|
||||
leaveCarry: Math.floor((arrival + minutes) / (24 * 60)) - Math.floor(arrival / (24 * 60)),
|
||||
}
|
||||
}, [stop?.arrival, minutes, is12h])
|
||||
|
||||
@@ -164,7 +169,15 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.leave')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
|
||||
{times.leave}
|
||||
{times.leaveCarry > 0 ? (
|
||||
<span className="ms-0.5">
|
||||
{`+${times.leaveCarry}`}
|
||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
|
||||
/**
|
||||
* FE-STOPFILL-001..010 — how full one stop fills, as opposed to how full they all do.
|
||||
*
|
||||
* The figure is two-sided: a stop either has its own or borrows the traveller's, and the
|
||||
* panel has to open showing which. What is worth pinning is the seam between them —
|
||||
* that an inherited figure reads as chosen, that picking it again hands the stop back to
|
||||
* the setting rather than writing the same number, and that the kilometre line under the
|
||||
* pills is absent rather than invented when no range is set.
|
||||
*/
|
||||
|
||||
let anchor: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
anchor = document.createElement('button')
|
||||
document.body.appendChild(anchor)
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_fill_percent: 80, roadtrip_range_km: 500, distance_unit: 'metric' } as never,
|
||||
})
|
||||
})
|
||||
afterEach(() => { anchor.remove() })
|
||||
|
||||
function open(over: { current?: number | null; onPick?: (p: number | null) => void } = {}) {
|
||||
const onPick = over.onPick ?? vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<StopFillPicker
|
||||
anchor={anchor}
|
||||
current={over.current ?? null}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
return { onPick, onClose }
|
||||
}
|
||||
|
||||
const pill = (percent: number) => screen.getByRole('button', { name: String(percent) })
|
||||
|
||||
describe('StopFillPicker', () => {
|
||||
it('FE-STOPFILL-001: without an anchor there is nothing to hang under', () => {
|
||||
render(<StopFillPicker anchor={null} current={null} onPick={vi.fn()} onClose={vi.fn()} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-002: the traveller own figure is the one shown as chosen', () => {
|
||||
// Nothing said about this stop, so it fills the way every other one does. Opening
|
||||
// with nothing pressed would suggest the stop has no answer, when it has one.
|
||||
open({ current: null })
|
||||
expect(pill(80)).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(pill(100)).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-003: the stop own figure wins over the traveller own', () => {
|
||||
open({ current: 60 })
|
||||
expect(pill(60)).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(pill(80)).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-004: picking a percentage reports it', () => {
|
||||
const { onPick } = open({ current: null })
|
||||
fireEvent.click(pill(60))
|
||||
expect(onPick).toHaveBeenCalledWith(60)
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-005: picking the figure the stop already has hands it back to the setting', () => {
|
||||
// The same gesture the kind picker uses to un-pick a kind. Writing 60 again would
|
||||
// leave the stop pinned to a number it only ever had by inheritance.
|
||||
const { onPick } = open({ current: 60 })
|
||||
fireEvent.click(pill(60))
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-006: an inherited figure can be pinned by picking it', () => {
|
||||
// Pressed because it is what the stop uses, but not its own — so clicking it means
|
||||
// "make this one mine", not "take it off".
|
||||
const { onPick } = open({ current: null })
|
||||
fireEvent.click(pill(80))
|
||||
expect(onPick).toHaveBeenCalledWith(80)
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-007: what the choice buys is stated, and only when it is known', () => {
|
||||
open({ current: 60 })
|
||||
// 60 % of a 500 km range.
|
||||
expect(screen.getByText(/300 km/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-008: with no range set the kilometres are absent rather than invented', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_fill_percent: 80, distance_unit: 'metric' } as never,
|
||||
})
|
||||
open({ current: 60 })
|
||||
expect(screen.queryByText(/km/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-009: the way back to the default appears only once there is one to undo', () => {
|
||||
// On a stop with no figure of its own it would be a button that says "leave
|
||||
// everything as it is".
|
||||
open({ current: null })
|
||||
expect(screen.queryByText('Use my default')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPFILL-010: a stop with its own figure can be handed back to the setting', () => {
|
||||
const { onPick } = open({ current: 60 })
|
||||
fireEvent.click(screen.getByText('Use my default'))
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import AnchoredPopover from './AnchoredPopover'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import { FS } from './typeScale'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
|
||||
/**
|
||||
* How full THIS stop fills up.
|
||||
*
|
||||
* A property of the stop rather than of the traveller, which is the whole reason it is
|
||||
* here and not only in the settings dialog: a motorway rapid charger is worth about 80 %
|
||||
* because the last fifth costs as long again, while the one at the hotel is worth all of
|
||||
* it because the car stands there all night. One figure for the whole trip cannot say
|
||||
* both, and the difference between them is a leg.
|
||||
*
|
||||
* Six pills rather than a slider or a field. A tank is not filled to 63 %, and the number
|
||||
* being chosen is a decision — "top up" or "fill right up" — not a measurement, so a
|
||||
* control that offers the six answers people actually give is one tap where a slider is a
|
||||
* drag and a field is a keyboard.
|
||||
*
|
||||
* Under them, what the choice is worth in kilometres, because 80 % is an abstraction and
|
||||
* "about 290 km from here" is the thing the traveller is actually deciding.
|
||||
*/
|
||||
|
||||
/** The answers people actually give. Below half a tank nobody stops on purpose. */
|
||||
const PRESETS = [50, 60, 70, 80, 90, 100] as const
|
||||
|
||||
export default function StopFillPicker({ anchor, current, onPick, onClose }: {
|
||||
/** The element the popover hangs under: the stop's own fill badge. */
|
||||
anchor: HTMLElement | null
|
||||
/** What this stop says, or null when it follows the traveller's own setting. */
|
||||
current: number | null
|
||||
/** A percentage for this stop, or null to hand it back to the setting. */
|
||||
onPick: (percent: number | null) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric'
|
||||
const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
||||
const { rangeKm } = useVehicleRange()
|
||||
|
||||
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
|
||||
// here too rather than as three different defaults.
|
||||
const settingPercent = fallback && fallback > 0 && fallback < 100 ? fallback : 100
|
||||
const shown = current ?? settingPercent
|
||||
|
||||
return (
|
||||
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.fillTitle')} onClose={onClose}>
|
||||
<div className="flex flex-col gap-2 p-0.5">
|
||||
<p
|
||||
className="px-1 font-geist font-semibold uppercase tracking-[0.12em] text-content-faint"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{t('roadtrip.stop.fillTitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{PRESETS.map(percent => {
|
||||
// Pressed against what the stop will actually use, inherited or not, so the
|
||||
// panel opens showing where the stop stands rather than showing nothing.
|
||||
const on = shown === percent
|
||||
return (
|
||||
<button
|
||||
key={percent}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
// Picking what it already is hands it back to the setting, the same way
|
||||
// the kind picker un-picks a kind it already has.
|
||||
onClick={() => onPick(current === percent ? null : percent)}
|
||||
className={`h-9 w-9 rounded-xl border text-caption font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
on
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge bg-surface-card text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{percent}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* What the choice buys. Absent rather than guessed when no range is set: a
|
||||
kilometre figure worked out from nothing would be the most convincing wrong
|
||||
number on the screen. */}
|
||||
<p className="px-1 text-caption text-content-muted">
|
||||
{rangeKm
|
||||
? t('roadtrip.stop.fillGives', { distance: formatDistance((rangeKm * shown) / 100, unit) })
|
||||
: t('roadtrip.stop.fillNoRange')}
|
||||
</p>
|
||||
|
||||
{/* Only once there is something to undo. Without an override it would be a button
|
||||
that says "leave everything as it is". */}
|
||||
{current !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(null)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<RotateCcw size={14} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="flex-1 text-start">{t('roadtrip.stop.fillDefault')}</span>
|
||||
<span className="tabular-nums text-content-faint">{`${settingPercent} %`}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import React from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import AnchoredPopover from './AnchoredPopover'
|
||||
import { STOP_KINDS } from './stopKinds'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
@@ -33,61 +33,9 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Measured after paint, before the browser shows it: reading the size first and then
|
||||
// placing it is what stops the panel appearing at 0,0 for one frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const p = ref.current.getBoundingClientRect()
|
||||
const gap = 8
|
||||
let top = a.bottom + gap
|
||||
// Flips above when there is no room below, which is most of the rail on a short
|
||||
// window: a panel clipped by the viewport is one that cannot be used at all.
|
||||
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
||||
// Centred on the column, not on the number. The number sits hard against the left
|
||||
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
|
||||
// the map. Vertically it still follows the row it belongs to.
|
||||
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
||||
const left = Math.min(
|
||||
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
||||
window.innerWidth - p.width - 8,
|
||||
)
|
||||
setPos({ top, left })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (anchor.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Any scroll moves the anchor out from under the panel, and following it would mean
|
||||
// measuring on every frame for a menu that is open for two seconds.
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!anchor) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={t('roadtrip.stop.kind')}
|
||||
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
||||
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
||||
>
|
||||
return (
|
||||
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.kind')} onClose={onClose}>
|
||||
<div className="flex gap-1">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
|
||||
const on = current === key
|
||||
@@ -127,7 +75,6 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
|
||||
{t('roadtrip.stop.backToDestination')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
</AnchoredPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
* reason this is not simply the midpoint.
|
||||
*/
|
||||
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry' }
|
||||
|
||||
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
|
||||
function line(n: number, lat: number): [number, number][] {
|
||||
|
||||
@@ -83,7 +83,7 @@ function mostDistinctPoint(
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
@@ -114,7 +114,9 @@ export function buildAlternativeOverlays(
|
||||
? labels.noMotorway
|
||||
: route.avoids === 'toll'
|
||||
? labels.noToll
|
||||
: index === quickest ? labels.fastest : '',
|
||||
: route.avoids === 'ferry'
|
||||
? labels.noFerry
|
||||
: index === quickest ? labels.fastest : '',
|
||||
duration: route.duration,
|
||||
distance: route.distance,
|
||||
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
boxAround,
|
||||
corridorTiles,
|
||||
distanceToSegmentKm,
|
||||
haversineKm,
|
||||
pointAtMeters,
|
||||
projectOntoRoute,
|
||||
sliceAtMeters,
|
||||
simplifyLine,
|
||||
type LatLng,
|
||||
} from './corridor'
|
||||
@@ -155,3 +158,106 @@ describe('simplifyLine', () => {
|
||||
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointAtMeters', () => {
|
||||
// A straight run due east along one parallel, so the arithmetic is checkable by hand:
|
||||
// at 52° north a degree of longitude is about 68.5 km.
|
||||
const WEST: LatLng = { lat: 52, lng: 13 }
|
||||
const EAST: LatLng = { lat: 52, lng: 14 }
|
||||
const LEG = haversineKm(WEST, EAST) * 1000
|
||||
|
||||
it('lands halfway along a single segment', () => {
|
||||
const mid = pointAtMeters([WEST, EAST], LEG / 2)!
|
||||
expect(mid.lat).toBeCloseTo(52, 5)
|
||||
expect(mid.lng).toBeCloseTo(13.5, 3)
|
||||
})
|
||||
|
||||
it('walks past the first segment into the second', () => {
|
||||
const line = [WEST, EAST, { lat: 52, lng: 15 }]
|
||||
const at = pointAtMeters(line, LEG * 1.5)!
|
||||
// One and a half segments in, so halfway through the second.
|
||||
expect(at.lng).toBeGreaterThan(14)
|
||||
expect(at.lng).toBeLessThan(15)
|
||||
})
|
||||
|
||||
it('returns the last point past the end rather than nothing', () => {
|
||||
// A tank that runs out after the day's final stop still ran out somewhere; the
|
||||
// caller decides what to do about that, so this must not answer null.
|
||||
expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST)
|
||||
})
|
||||
|
||||
it('answers the first point for zero, and nothing for an empty line', () => {
|
||||
expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST)
|
||||
expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST)
|
||||
expect(pointAtMeters([], 100)).toBeNull()
|
||||
expect(pointAtMeters([WEST], 100)).toEqual(WEST)
|
||||
})
|
||||
|
||||
it('survives a repeated vertex without dividing by zero', () => {
|
||||
// A route line concatenated from several runs does contain these.
|
||||
const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)!
|
||||
expect(at.lng).toBeCloseTo(13.5, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boxAround', () => {
|
||||
it('reaches the asked radius on all four sides', () => {
|
||||
const box = boxAround(BERLIN, 10)
|
||||
// North and south are the easy ones: a degree of latitude is constant.
|
||||
expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0)
|
||||
expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0)
|
||||
})
|
||||
|
||||
it('widens the longitude span the further north it sits', () => {
|
||||
// The same radius is more degrees of longitude in Tromsø than in Berlin.
|
||||
const berlin = boxAround(BERLIN, 10)
|
||||
const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10)
|
||||
expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sliceAtMeters', () => {
|
||||
// A degree of latitude is about 111 km, so this line is roughly 111 km long and every
|
||||
// figure below can be read off it without a calculator.
|
||||
const line: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
|
||||
|
||||
it('cuts a stretch out of the middle and interpolates both ends', () => {
|
||||
const out = sliceAtMeters(line, 30_000, 60_000)
|
||||
expect(out).toHaveLength(2)
|
||||
expect(haversineKm(out[0], out[1]) * 1000).toBeGreaterThan(29_000)
|
||||
expect(haversineKm(out[0], out[1]) * 1000).toBeLessThan(31_000)
|
||||
})
|
||||
|
||||
it('two adjacent slices meet exactly, so the parts rebuild the whole', () => {
|
||||
// The reason this exists: a day's line is its legs end to end, and a night drive
|
||||
// takes its own leg to the next card. A gap at the join would draw as a break in
|
||||
// the road.
|
||||
const first = sliceAtMeters(line, 0, 40_000)
|
||||
const second = sliceAtMeters(line, 40_000, 80_000)
|
||||
const end = first[first.length - 1]
|
||||
expect(haversineKm(end, second[0])).toBeLessThan(0.001)
|
||||
})
|
||||
|
||||
it('clamps to the line rather than running past its end', () => {
|
||||
const out = sliceAtMeters(line, 0, 999_000_000)
|
||||
expect(out[out.length - 1]).toEqual(line[line.length - 1])
|
||||
})
|
||||
|
||||
it('is empty for a zero-length or backwards range', () => {
|
||||
expect(sliceAtMeters(line, 50_000, 50_000)).toEqual([])
|
||||
expect(sliceAtMeters(line, 60_000, 30_000)).toEqual([])
|
||||
})
|
||||
|
||||
it('is empty for a line that is not one', () => {
|
||||
expect(sliceAtMeters([], 0, 100)).toEqual([])
|
||||
expect(sliceAtMeters([{ lat: 52, lng: 13 }], 0, 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('survives a repeated vertex without producing NaN', () => {
|
||||
// A concatenated route line really does contain these, and dividing by a zero-length
|
||||
// segment is how a route full of NaN reaches the map.
|
||||
const doubled: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 52, lng: 13 }, { lat: 53, lng: 13 }]
|
||||
const out = sliceAtMeters(doubled, 10_000, 20_000)
|
||||
expect(out.every(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -232,3 +232,111 @@ export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
|
||||
const right = simplifyLine(line.slice(index), toleranceKm)
|
||||
return [...left.slice(0, -1), ...right]
|
||||
}
|
||||
|
||||
/**
|
||||
* The point that lies `metres` along a line, measured the way a car drives it.
|
||||
*
|
||||
* Every other helper in this file goes the other way: given a point, where on the line
|
||||
* is it. This is the inverse, and it exists because the range budget produces a distance
|
||||
* ("the tank is empty 340 km in") with no idea where that is on the map.
|
||||
*
|
||||
* Walked segment by segment rather than approximated, and interpolated inside the
|
||||
* segment it lands in, because the answer becomes a marker somebody drives to. Past the
|
||||
* end it returns the last point rather than null: a tank that runs out after the day's
|
||||
* final stop still ran out somewhere, and the caller decides what to do about that.
|
||||
*/
|
||||
export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
|
||||
if (!line.length) return null
|
||||
if (line.length === 1 || metres <= 0) return line[0]
|
||||
|
||||
let covered = 0
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const step = haversineKm(line[i - 1], line[i]) * 1000
|
||||
if (covered + step >= metres) {
|
||||
// How far into this segment, as a fraction. A zero-length segment (two identical
|
||||
// vertices, which a concatenated route line does produce) would divide by zero,
|
||||
// and its start point is the right answer anyway.
|
||||
const t = step > 0 ? (metres - covered) / step : 0
|
||||
return {
|
||||
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
|
||||
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
|
||||
}
|
||||
}
|
||||
covered += step
|
||||
}
|
||||
return line[line.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a line is, in metres, measured the way `sliceAtMeters` walks it.
|
||||
*
|
||||
* Not the same figure the router reports for the same road: that comes off its own graph
|
||||
* while this is great-circle hops between the vertices it sent back. Anything cutting a
|
||||
* line at a distance has to measure it with this, or the last cut falls short of the end
|
||||
* by the difference.
|
||||
*/
|
||||
export function lineMetres(line: LatLng[]): number {
|
||||
let total = 0
|
||||
for (let i = 1; i < line.length; i++) total += haversineKm(line[i - 1], line[i]) * 1000
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretch of a line between two distances along it, ends included.
|
||||
*
|
||||
* The router answers a whole run with one polyline and a leg list beside it, so the road
|
||||
* belonging to any single leg has to be cut out afterwards. Which matters because a road
|
||||
* trip draws its stops by the day they are REACHED: a drive across midnight hands its
|
||||
* stops to the next card, and the road under them has to go with them — otherwise the
|
||||
* day they set off from keeps a line nobody drives on it, and the corridor search along
|
||||
* "day 2" looks at road that was covered yesterday.
|
||||
*
|
||||
* Both ends are interpolated inside the segment they fall in rather than snapped to the
|
||||
* nearest vertex, so two adjacent slices meet exactly and concatenating them gives the
|
||||
* original line back.
|
||||
*/
|
||||
export function sliceAtMeters(line: LatLng[], fromMetres: number, toMetres: number): LatLng[] {
|
||||
if (line.length < 2 || toMetres <= fromMetres) return []
|
||||
const from = Math.max(0, fromMetres)
|
||||
const out: LatLng[] = []
|
||||
let covered = 0
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const step = haversineKm(line[i - 1], line[i]) * 1000
|
||||
const segStart = covered
|
||||
const segEnd = covered + step
|
||||
covered = segEnd
|
||||
if (segEnd < from) continue
|
||||
if (segStart > toMetres) break
|
||||
// A zero-length segment divides by zero; both its ends are the same point, so the
|
||||
// fraction does not matter and zero is the one that never produces NaN.
|
||||
const at = (m: number): LatLng => {
|
||||
const t = step > 0 ? (m - segStart) / step : 0
|
||||
return {
|
||||
lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t,
|
||||
lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t,
|
||||
}
|
||||
}
|
||||
if (!out.length) out.push(segStart >= from ? line[i - 1] : at(from))
|
||||
out.push(segEnd <= toMetres ? line[i] : at(toMetres))
|
||||
}
|
||||
return out.length > 1 ? out : []
|
||||
}
|
||||
|
||||
/**
|
||||
* A box around a point, sized so its inscribed circle reaches `radiusKm`.
|
||||
*
|
||||
* The server turns a box into centre plus half the diagonal, so a box of ±r asks for
|
||||
* r·√2 of reach and finds things in the corners that are further away than they look.
|
||||
* Sized from the radius rather than guessed, and the caller still has to read the
|
||||
* answer's own `clamped` flag: above 20 km the index narrows the search silently.
|
||||
*/
|
||||
export function boxAround(point: LatLng, radiusKm: number): Bbox {
|
||||
const dLat = radiusKm / KM_PER_DEG_LAT
|
||||
const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat))))
|
||||
return {
|
||||
south: point.lat - dLat,
|
||||
west: point.lng - dLng,
|
||||
north: point.lat + dLat,
|
||||
east: point.lng + dLng,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { dayColor } from './dayColors'
|
||||
|
||||
describe('dayColor', () => {
|
||||
it('FE-DAYCOLORS-001: day one is the blue the route has always been', () => {
|
||||
// So a one-day trip looks exactly as it did before colouring by day existed.
|
||||
expect(dayColor(1).line).toBe('#0a84ff')
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-002: consecutive days differ', () => {
|
||||
const seen = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(n => dayColor(n).line))
|
||||
expect(seen.size).toBe(8)
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-003: the ninth day starts the palette again', () => {
|
||||
expect(dayColor(9)).toEqual(dayColor(1))
|
||||
expect(dayColor(17)).toEqual(dayColor(1))
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-004: the casing is its own colour, not the core', () => {
|
||||
// A single fixed casing went muddy under the warm colours, which is why each entry
|
||||
// carries a darkened hue of its own.
|
||||
for (const n of [1, 2, 3, 4, 5, 6, 7, 8]) {
|
||||
expect(dayColor(n).casing).not.toBe(dayColor(n).line)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-DAYCOLORS-005: a missing or nonsensical day still draws something', () => {
|
||||
// A day number is read off data, and a route with no colour at all draws nothing.
|
||||
expect(dayColor(0)).toEqual(dayColor(1))
|
||||
expect(dayColor(-3)).toEqual(dayColor(1))
|
||||
expect(dayColor(Number.NaN)).toEqual(dayColor(1))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* A colour per day, for a road trip drawn end to end.
|
||||
*
|
||||
* The map draws one blue line for a whole trip, which is right while the days are read as
|
||||
* separate cards. Once the days are joined into one continuous drive there is nothing in
|
||||
* the line to say where one ends, so the colour has to do it.
|
||||
*
|
||||
* Fixed hex rather than theme tokens, and deliberately so: these sit on map tiles, not on
|
||||
* the app's own surfaces, and a token that follows the user's accent would put the route
|
||||
* in the same colour as the terrain on some schemes. The same exception the rest of the
|
||||
* map's paint takes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Eight, then it repeats.
|
||||
*
|
||||
* Picked for separation at a 5px stroke over a pale basemap rather than for a gradient: a
|
||||
* ramp reads as "later" and days are not a quantity. Blue stays first because it is the
|
||||
* colour the route has always been, so a one-day trip looks unchanged.
|
||||
*
|
||||
* The second value is the casing under the core — the same hue darkened, which is what
|
||||
* keeps the line readable over water and forest. A single fixed casing would have gone
|
||||
* muddy under the warm colours.
|
||||
*/
|
||||
const DAY_COLORS: readonly [string, string][] = [
|
||||
['#0a84ff', '#0a5cc2'], // theme-lint-disable — map paint, see file comment
|
||||
['#ff9f0a', '#c2740a'], // theme-lint-disable
|
||||
['#30d158', '#1f9c40'], // theme-lint-disable
|
||||
['#bf5af2', '#8e3fb8'], // theme-lint-disable
|
||||
['#ff375f', '#c22546'], // theme-lint-disable
|
||||
['#64d2ff', '#3d9dc2'], // theme-lint-disable
|
||||
['#ffd60a', '#c2a30a'], // theme-lint-disable
|
||||
['#ac8e68', '#7d654a'], // theme-lint-disable
|
||||
]
|
||||
|
||||
/** The core and casing a given day of the trip is drawn in. Day numbers start at 1. */
|
||||
export function dayColor(dayNumber: number): { line: string; casing: string } {
|
||||
// A day number that is missing or nonsensical still has to draw something, and the
|
||||
// first colour is the one the route had before any of this existed.
|
||||
const at = Number.isFinite(dayNumber) && dayNumber > 0 ? (dayNumber - 1) % DAY_COLORS.length : 0
|
||||
const [line, casing] = DAY_COLORS[at]
|
||||
return { line, casing }
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { spillChains } from './nightSpill'
|
||||
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
|
||||
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
|
||||
return {
|
||||
ownerDayId: 1,
|
||||
ownerIndex: 0,
|
||||
placeId: over.assignmentId * 10,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** A day of the plan, with every stop's stored position filled in for it. */
|
||||
function day(dayId: number, dayNumber: number, stops: RoadtripStop[]): PlanDay {
|
||||
return {
|
||||
dayId,
|
||||
dayNumber,
|
||||
date: null,
|
||||
title: null,
|
||||
stops: stops.map((s, i) => ({ ...s, ownerDayId: dayId, ownerIndex: i })),
|
||||
}
|
||||
}
|
||||
|
||||
const seg = (minutes: number, km = 100): RouteSegment =>
|
||||
({ distance: km * 1000, duration: minutes * 60, distanceText: `${km} km`, durationText: `${minutes} min`, mode: 'driving' }) as RouteSegment
|
||||
|
||||
/** Every leg the same length, which is all most of these cases need. */
|
||||
const everyLeg = (minutes: number, km = 100) => () => ({ seg: seg(minutes, km), line: [[0, 0], [1, 1]] as [number, number][] })
|
||||
|
||||
/** No leg routed at all, which is what an unrouted or offline trip looks like. */
|
||||
const noLegs = () => undefined
|
||||
|
||||
describe('spillChains', () => {
|
||||
it('FE-NIGHTSPILL-001: leaves an ordinary day exactly where it is', () => {
|
||||
const plan = [day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 60 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
])]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains).toHaveLength(1)
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Berlin'])
|
||||
expect(chains[0].spills).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-002: hands the stops reached after midnight to the next day', () => {
|
||||
// 21:00 + 1 h 30 stay = 22:30, then a three hour drive arrives at 01:30 tomorrow.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [
|
||||
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
|
||||
stop({ assignmentId: 4, name: 'Leipzig' }),
|
||||
]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg'])
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Wittenberg', 'Leipzig'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-003: the spill carries the night drive and the time it set off', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [
|
||||
stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }),
|
||||
stop({ assignmentId: 4, name: 'Leipzig' }),
|
||||
]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180, 240))
|
||||
|
||||
expect(chains[1].spills).toHaveLength(1)
|
||||
const spill = chains[1].spills[0]
|
||||
expect(spill).toMatchObject({ at: 0, count: 1, fromDayNumber: 1, departure: '22:30' })
|
||||
// The leg is the drive that crossed midnight, not the one after it: without it the
|
||||
// card would gain a stop and none of the distance that reaches it.
|
||||
expect(spill.leg?.distance).toBe(240000)
|
||||
expect(spill.line.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-004: a stop that moved keeps the day the server stores it on', () => {
|
||||
const plan = [
|
||||
day(7, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(8, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
// Drawn under day 2, stored on day 1 — which is what every callback still names.
|
||||
const moved = chains[1].stops.find(s => s.name === 'Neuruppin')
|
||||
expect(moved?.ownerDayId).toBe(7)
|
||||
expect(moved?.ownerIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-005: the last day of a trip keeps its own crossing, having nowhere to send it', () => {
|
||||
const plan = [day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
])]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains).toHaveLength(1)
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
|
||||
expect(chains[0].spills).toEqual([])
|
||||
// And it still says so: the arrival is a day past the one the card is under.
|
||||
expect(chains[0].schedule.entries[1].dayOffset).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-006: a pinned time resolved to yesterday does not tear the day apart', () => {
|
||||
// The case that broke it: 07:00 then 23:00. `computeSchedule` resolves the second pin
|
||||
// to the occurrence nearest the drive reaching it, so nine hours late reads as nearer
|
||||
// than fifteen hours of waiting and 23:00 lands on the PREVIOUS day. The whole chain
|
||||
// is then lifted to stay positive, which leaves the FIRST stop wearing a day offset
|
||||
// and the one after it none. Placing stops on those offsets moved the early stop to
|
||||
// tomorrow and left the late one behind, visiting them out of order.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Cobbelsdorf', time: '07:00', dwellMinutes: 45 }),
|
||||
stop({ assignmentId: 2, name: 'Wittenberg', time: '23:00', dwellMinutes: 180 }),
|
||||
stop({ assignmentId: 3, name: 'Leipzig' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 4, name: 'Dresden', time: '12:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(50))
|
||||
|
||||
// Cobbelsdorf and Wittenberg both stay: nothing between them crosses midnight.
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Cobbelsdorf', 'Wittenberg'])
|
||||
// Leipzig is reached at 02:50, after the clock went back past 23:00, so it moves.
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Leipzig', 'Dresden'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-007: two nights land on two different days', () => {
|
||||
// 20:00, then two twelve-hour drives: 08:00 the next day, 20:00 that evening, and
|
||||
// 08:00 the day after — two backwards jumps of the clock, two days on.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'A', time: '20:00' }),
|
||||
stop({ assignmentId: 2, name: 'B' }),
|
||||
stop({ assignmentId: 3, name: 'C' }),
|
||||
stop({ assignmentId: 4, name: 'D' }),
|
||||
]),
|
||||
day(2, 2, []),
|
||||
day(3, 3, []),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(12 * 60))
|
||||
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['A'])
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['B', 'C'])
|
||||
expect(chains[2].stops.map(s => s.name)).toEqual(['D'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-008: a day whose legs never routed moves nothing', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00' }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], noLegs)
|
||||
|
||||
// No drive, no arrival, no crossing — and a guess would be worse than nothing.
|
||||
expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin'])
|
||||
expect(chains[1].spills).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-009: a day that carries no drive of its own still receives one', () => {
|
||||
const plan = [day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
])]
|
||||
const quiet: QuietDay[] = [{ dayId: 2, dayNumber: 2, date: null, title: null, stops: [] }]
|
||||
|
||||
const chains = spillChains(plan, quiet, everyLeg(180))
|
||||
|
||||
expect(chains).toHaveLength(2)
|
||||
expect(chains[1].dayId).toBe(2)
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin'])
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-010: the crossing is not marked twice on a stop that moved', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
// The block around it is what says the night happened; a band as well would say it
|
||||
// twice. And it is not "the next day" once it is drawn under the next day's date.
|
||||
expect(chains[1].schedule.warnings.some(w => w.code === 'overnight')).toBe(false)
|
||||
expect(chains[1].schedule.entries[0].dayOffset).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-011: a finding about a stop travels with it, renumbered', () => {
|
||||
// Wittenberg is pinned two hours before the drive can get there, which is a "late"
|
||||
// finding against index 1 of day 1. It moves to day 2 as index 0.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin', time: '00:30' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
const late = chains[1].schedule.warnings.filter(w => w.code === 'late')
|
||||
expect(late).toHaveLength(1)
|
||||
expect(late[0].index).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-NIGHTSPILL-012: two stops crossing together are one block, not two', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Neuruppin' }),
|
||||
stop({ assignmentId: 3, name: 'Potsdam' }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]),
|
||||
]
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(180))
|
||||
|
||||
expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg'])
|
||||
expect(chains[1].spills).toHaveLength(1)
|
||||
expect(chains[1].spills[0]).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { computeSchedule, parseClock, type Schedule } from './roadtripModel'
|
||||
import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/**
|
||||
* Which date each stop of a road trip is drawn on.
|
||||
*
|
||||
* A day plan stores a stop against a day, and that is the right model for planning: the
|
||||
* traveller put it there. But a road trip is read as a drive, and a drive that leaves at
|
||||
* 21:00 and arrives at 01:23 arrives TOMORROW. The rail used to say so with a band
|
||||
* between the two stops and then carry on listing the arrival under yesterday's date —
|
||||
* the one thing the band had just said was wrong.
|
||||
*
|
||||
* So a road trip lays its stops out by the day they are actually reached, and the drive
|
||||
* across midnight travels with them, which is what makes the kilometres count on the day
|
||||
* they are driven rather than on the day they were set off on.
|
||||
*
|
||||
* **Nothing is written.** The stop still belongs to the day it was planned on, the day
|
||||
* plan shows it there, and every stop carries `ownerDayId`/`ownerIndex` so a reorder, a
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. Because
|
||||
* this is worked out again from the arrival times every time they change, shortening the
|
||||
* stay before a night drive brings the stop back on its own — there is no state to undo,
|
||||
* and no way for the arrangement and the times to disagree.
|
||||
*/
|
||||
|
||||
/** A stretch of one card's chain that was driven onto it from an earlier day. */
|
||||
export interface SpillMark {
|
||||
/** Where it starts in the card's `stops`. */
|
||||
at: number
|
||||
/** How many stops it covers. */
|
||||
count: number
|
||||
/** The day they are stored on, as the traveller numbers it. */
|
||||
fromDayNumber: number
|
||||
/** When the drive set off, read off that day's own schedule. */
|
||||
departure: string | null
|
||||
/** The drive across midnight itself, or undefined when that leg never routed. */
|
||||
leg: RouteSegment | undefined
|
||||
/**
|
||||
* The stop the night drive LEAVES from, still on the earlier card.
|
||||
*
|
||||
* Needed because that drive is drawn on this card while belonging, leg-wise, to the
|
||||
* stop before it: anything measured along this card's line that lands on the incoming
|
||||
* stretch has to be anchored to this stop, not to the first one drawn here. Putting a
|
||||
* via there and anchoring it to the wrong stop is what made a route double back.
|
||||
*/
|
||||
fromStop: RoadtripStop | undefined
|
||||
/**
|
||||
* The road it covers, so the card it lands on can draw it and count it.
|
||||
*
|
||||
* Without this the night is a hole: the day it set off from ends before it and the day
|
||||
* it arrives on starts after it, so its kilometres belong to neither and the map draws
|
||||
* two runs with a gap between them.
|
||||
*/
|
||||
line: [number, number][]
|
||||
}
|
||||
|
||||
/** One date's worth of driving, before the legs and warnings are hung off it. */
|
||||
export interface SpillChain {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** In driving order: anything inherited first, then the day's own. */
|
||||
stops: RoadtripStop[]
|
||||
/** Arrivals as each stop's OWN day worked them out, renumbered onto this chain. */
|
||||
schedule: Schedule
|
||||
/** The inherited stretches, in the order they appear. Empty on an ordinary day. */
|
||||
spills: SpillMark[]
|
||||
}
|
||||
|
||||
/** A day as the plan holds it, before anything is routed onto it. */
|
||||
type PlanDay = Pick<RoadtripDay, 'dayId' | 'dayNumber' | 'date' | 'title' | 'stops'>
|
||||
|
||||
/** What the caller can find out about the road between two stops. */
|
||||
type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined
|
||||
|
||||
/** One stop on its way to a card, with the times its own day gave it. */
|
||||
interface Placed {
|
||||
stop: RoadtripStop
|
||||
arrival: string | null
|
||||
departure: string | null
|
||||
/** Days past its own day's first stop. Renumbered against the card it lands on. */
|
||||
dayOffset: number
|
||||
/** Findings its own day filed against it, to be renumbered with it. */
|
||||
codes: ('late' | 'overnight' | 'leg' | 'range')[]
|
||||
lateMinutes: number | null
|
||||
/** Which day it came from, or null when it is the card's own. */
|
||||
fromDayNumber: number | null
|
||||
departedAt: string | null
|
||||
leg: RouteSegment | undefined
|
||||
line: [number, number][]
|
||||
from: RoadtripStop | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays a trip out by the date each stop is reached.
|
||||
*
|
||||
* Every day of the trip gets a chain, in trip order, including the ones that carry no
|
||||
* drive of their own — a day whose only content was driven onto it through the night is
|
||||
* still a day, and the alternative is a date that silently disappears. The caller drops
|
||||
* the chains that end up with fewer than two stops, exactly as it did before.
|
||||
*/
|
||||
export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] {
|
||||
const all: PlanDay[] = [...plan, ...quietDays.map(d => ({
|
||||
dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops,
|
||||
}))].sort((a, b) => a.dayNumber - b.dayNumber)
|
||||
const numbers = new Set(all.map(d => d.dayNumber))
|
||||
const landing = new Map<number, Placed[]>()
|
||||
for (const d of all) landing.set(d.dayNumber, [])
|
||||
|
||||
for (const d of all) {
|
||||
// A day's own schedule, walked over its own stops: this is what says where midnight
|
||||
// falls, and it is the only reading of these times that is anchored to the times the
|
||||
// traveller actually pinned.
|
||||
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]))
|
||||
const legs = routed.map(l => l?.seg)
|
||||
const schedule = computeSchedule(
|
||||
d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
|
||||
legs.map(l => l?.duration),
|
||||
)
|
||||
/**
|
||||
* Which day of this chain each stop is reached on, read off the clock itself.
|
||||
*
|
||||
* Not `dayOffset`. That is arithmetic on top of a pinned time resolved to the
|
||||
* occurrence NEAREST the drive reaching it — so a stop pinned at 23:00 after one
|
||||
* pinned at 07:00 resolves to 23:00 yesterday (nine hours late reads as nearer than
|
||||
* fifteen hours of waiting), the chain drops below midnight, and the schedule lifts
|
||||
* the whole day back up to keep it positive. The result is an early stop wearing a
|
||||
* higher offset than the one after it, and placing stops on those offsets tears the
|
||||
* drive into pieces that are visited out of order.
|
||||
*
|
||||
* A clock that goes backwards is the one signal none of that can fake. Arrivals only
|
||||
* ever move forward along a drive, so 23:00 followed by 03:12 is one thing and one
|
||||
* thing only: midnight happened in between. A stop with no arrival yet inherits the
|
||||
* day of the one before it rather than resetting the count.
|
||||
*/
|
||||
let day = 0
|
||||
let previous: number | null = null
|
||||
d.stops.forEach((stop, i) => {
|
||||
const entry = schedule.entries[i]
|
||||
const clock = parseClock(entry?.arrival)
|
||||
if (clock !== null) {
|
||||
if (previous !== null && clock < previous) day += 1
|
||||
previous = clock
|
||||
}
|
||||
const offset = day
|
||||
const marks = schedule.warnings.filter(w => w.index === i)
|
||||
// A day the trip does not have is not a day to move onto. Those stops stay where
|
||||
// they are and keep the crossing marked the way the rail always marked it, because
|
||||
// the only other answer would be to invent a date nobody planned. The ratchet stays
|
||||
// where it was too, so the rest of the chain stays with them.
|
||||
const reachable = offset > 0 && numbers.has(d.dayNumber + offset)
|
||||
const target = reachable ? d.dayNumber + offset : d.dayNumber
|
||||
const moved = target !== d.dayNumber
|
||||
landing.get(target)?.push({
|
||||
stop,
|
||||
arrival: entry?.arrival ?? null,
|
||||
departure: entry?.departure ?? null,
|
||||
// Renumbered against the card it lands on: 01:23 is not "the next day" once it is
|
||||
// drawn under the next day's date. A stop that could not move keeps its offset,
|
||||
// which is what still makes the "+1" and the crossing band read correctly.
|
||||
dayOffset: moved ? 0 : offset,
|
||||
// The crossing itself is the spill block's own head wherever the stop moved, so
|
||||
// it would be said twice.
|
||||
codes: marks.map(w => w.code).filter(c => !(moved && c === 'overnight')),
|
||||
lateMinutes: marks.find(w => w.code === 'late')?.minutes ?? null,
|
||||
fromDayNumber: moved ? d.dayNumber : null,
|
||||
departedAt: moved ? schedule.entries[i - 1]?.departure ?? null : null,
|
||||
leg: moved ? legs[i - 1] : undefined,
|
||||
line: moved ? routed[i - 1]?.line ?? [] : [],
|
||||
from: moved ? d.stops[i - 1] : undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const out: SpillChain[] = []
|
||||
for (const d of all) {
|
||||
const placed = landing.get(d.dayNumber) ?? []
|
||||
if (!placed.length) continue
|
||||
// Inherited stretches first, oldest day first, then the card's own stops: that is
|
||||
// the order they are driven in.
|
||||
placed.sort((a, b) => (a.fromDayNumber ?? Number.MAX_SAFE_INTEGER) - (b.fromDayNumber ?? Number.MAX_SAFE_INTEGER))
|
||||
const spills: SpillMark[] = []
|
||||
for (let i = 0; i < placed.length; i++) {
|
||||
const p = placed[i]
|
||||
if (p.fromDayNumber === null) continue
|
||||
const last = spills[spills.length - 1]
|
||||
if (last && last.fromDayNumber === p.fromDayNumber && last.at + last.count === i) last.count += 1
|
||||
else spills.push({ at: i, count: 1, fromDayNumber: p.fromDayNumber, departure: p.departedAt, leg: p.leg, line: p.line, fromStop: p.from })
|
||||
}
|
||||
out.push({
|
||||
dayId: d.dayId,
|
||||
dayNumber: d.dayNumber,
|
||||
date: d.date,
|
||||
title: d.title,
|
||||
stops: placed.map(p => p.stop),
|
||||
schedule: {
|
||||
entries: placed.map(p => ({
|
||||
arrival: p.arrival,
|
||||
departure: p.departure,
|
||||
anchored: p.stop.time !== null && p.stop.time !== undefined,
|
||||
dayOffset: p.dayOffset,
|
||||
})),
|
||||
warnings: placed.flatMap((p, i) => p.codes.map(code => (
|
||||
code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code }
|
||||
))),
|
||||
},
|
||||
spills,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { reachableRefuels, outcomeOf } from './refuelSuggestion'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
// A straight run due east at 52° north, where a degree of longitude is about 68.5 km.
|
||||
const LINE: LatLng[] = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52, lng: 14 },
|
||||
{ lat: 52, lng: 15 },
|
||||
]
|
||||
|
||||
const poi = (name: string, lat: number, lng: number): Poi =>
|
||||
({ osm_id: `node:${name}`, name, lat, lng } as Poi)
|
||||
|
||||
describe('reachableRefuels', () => {
|
||||
it('FE-ROADTRIP-REFUEL-001: offers only what lies before the tank runs out', () => {
|
||||
// Dry at 100 km in. A pump at 50 km is reachable; one at 130 km is on the far side
|
||||
// of an empty tank, and offering it is offering a walk.
|
||||
const early = poi('early', 52, 13.73) // ~50 km
|
||||
const late = poi('late', 52, 14.9) // ~130 km
|
||||
const out = reachableRefuels([early, late], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['early'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-002: the detour counts twice, because it is driven out and back', () => {
|
||||
// Both sit at the same point along the road, 60 km in, with 100 km of range. One is
|
||||
// on the road; the other is 20 km off it, so 40 km of driving, which no longer fits
|
||||
// in the 40 km that are left once the reserve is kept back.
|
||||
const onRoad = poi('on-road', 52, 13.876)
|
||||
const offRoad = poi('off-road', 52.18, 13.876) // ~20 km north
|
||||
const out = reachableRefuels([onRoad, offRoad], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['on-road'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-003: a reserve is kept back, so nobody is sent to the last drop', () => {
|
||||
// 8 km before the dry point is inside the ten kilometre reserve.
|
||||
const onTheLimit = poi('on-the-limit', 52, 14.34) // ~93 km
|
||||
expect(reachableRefuels([onTheLimit], LINE, 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-004: the smallest detour comes first, however late the others sit', () => {
|
||||
// Measured on a real day: the station that got furthest was 9.2 km off the road for
|
||||
// one kilometre more of range. Everything in this list is already reachable, so what
|
||||
// separates them is what the stop costs.
|
||||
const nearRoad = poi('near-road', 52, 13.15) // on the line, early
|
||||
const bigDetour = poi('big-detour', 52.05, 13.9) // ~5.5 km off, later
|
||||
const out = reachableRefuels([bigDetour, nearRoad], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['near-road', 'big-detour'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-009: with the same detour, the later one wins', () => {
|
||||
// Filling up early wastes the tank that was already paid for.
|
||||
const early = poi('early', 52, 13.15)
|
||||
const later = poi('later', 52, 14.16)
|
||||
const out = reachableRefuels([early, later], LINE, 100)
|
||||
expect(out.map(p => p.name)).toEqual(['later', 'early'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-005: a pump already on the plan is not suggested next to itself', () => {
|
||||
// Matched on position, not on id: the same station added by hand or from another
|
||||
// source carries a different osm_id.
|
||||
const pump = poi('pump', 52, 13.73)
|
||||
const out = reachableRefuels([pump], LINE, 100, { existing: [{ lat: 52.0005, lng: 13.7302 }] })
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-006: something nowhere near the road is not on the way', () => {
|
||||
const wayOff = poi('way-off', 49, 13.5)
|
||||
expect(reachableRefuels([wayOff], LINE, 100)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('outcomeOf', () => {
|
||||
it('FE-ROADTRIP-REFUEL-007: no answer is not the same as no filling station', () => {
|
||||
// The distinction the whole band hangs on. Saying "none on this stretch" after a
|
||||
// failed request states a fact that was never checked.
|
||||
expect(outcomeOf([], null)).toBe('failed')
|
||||
expect(outcomeOf([], { truncated: true })).toBe('incomplete')
|
||||
expect(outcomeOf([], {})).toBe('none')
|
||||
// `clamped` is not the same thing: it means the server searched a smaller circle
|
||||
// than the box described, and the caller sizes the box so that never happens.
|
||||
// Treating it as "could not check" turned every empty answer into a shrug.
|
||||
expect(outcomeOf([], { clamped: true })).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => {
|
||||
const found = [{ name: 'x' }] as never
|
||||
expect(outcomeOf(found, { truncated: true })).toBe('found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reachableRefuels — one entry per station', () => {
|
||||
const at = (name: string, lat: number, lng: number): Poi =>
|
||||
({ osm_id: `node:${name}-${lat}-${lng}`, name, lat, lng } as Poi)
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-010: a charging site mapped as one node per socket is offered once', () => {
|
||||
// Measured on a real day: the top three offers were the same "autostrom plus GmbH"
|
||||
// three times over, because OSM maps every socket as its own node.
|
||||
// The real shape of it: four nodes in two pairs, 6 m and 7 m apart, with 450 m
|
||||
// between the pairs.
|
||||
const sockets = [
|
||||
at('autostrom plus GmbH', 52, 13.3),
|
||||
at('autostrom plus GmbH', 52.00005, 13.30005),
|
||||
at('autostrom plus GmbH', 52.004, 13.3005),
|
||||
at('autostrom plus GmbH', 52.00405, 13.30045),
|
||||
]
|
||||
const out = reachableRefuels(sockets, LINE, 100)
|
||||
expect(out).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-011: the same chain in two towns stays two entries', () => {
|
||||
// Name alone is not identity: a chain has a branch everywhere.
|
||||
// Measured: genuinely different branches were 18 to 36 km apart.
|
||||
const out = reachableRefuels([at('Aral', 52, 13.2), at('Aral', 52, 13.6)], LINE, 100)
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-012: two operators sharing one services are both offered', () => {
|
||||
// Position alone is not identity either.
|
||||
const out = reachableRefuels([at('Aral', 52, 13.3), at('Shell', 52.0003, 13.3)], LINE, 100)
|
||||
expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
/**
|
||||
* Picking a filling station for a tank that is about to run out.
|
||||
*
|
||||
* Pure: no React, no network. The caller does the one request and hands the answers
|
||||
* here, which keeps the judgement — which of these can the car actually reach — testable
|
||||
* without a server.
|
||||
*/
|
||||
|
||||
/** A candidate, with the two figures that decide whether it is any use. */
|
||||
export interface RefuelCandidate extends Poi {
|
||||
/** Kilometres along the day's driving line, in the same space as the dry point. */
|
||||
alongKm: number
|
||||
/** How far off the road it sits. */
|
||||
offRouteKm: number
|
||||
/** Kilometres of range still in the tank when the car draws level with it. */
|
||||
spareKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the last drop to keep back, in kilometres.
|
||||
*
|
||||
* Arriving at a pump with nothing left is not a plan, and the range figure a traveller
|
||||
* types is a round number from a manual rather than a measurement. Ten kilometres is
|
||||
* small enough not to move the suggestion and large enough not to promise the
|
||||
* impossible.
|
||||
*/
|
||||
const RESERVE_KM = 10
|
||||
|
||||
/**
|
||||
* The stations that can actually be reached, nearest to the dry point first.
|
||||
*
|
||||
* Three filters, and each one exists because its absence produces a wrong answer rather
|
||||
* than an untidy one:
|
||||
*
|
||||
* - Only what lies BEFORE the point the tank empties. A station 20 km past it is on the
|
||||
* far side of an empty tank, and offering it is offering a walk.
|
||||
* - The detour counts double. A pump 8 km off the road is 16 km of driving, and on a
|
||||
* tank this close to empty that is the difference between arriving and not.
|
||||
* - What is left has to fit in the range that remains, minus a reserve.
|
||||
*
|
||||
* Sorted by the detour, not by how far along the road it sits. Measured against a real
|
||||
* day: the station that got furthest was 9.2 km off the road for one kilometre more of
|
||||
* range, while one 0.9 km off sat almost as late. Everything in this list is already
|
||||
* reachable, so what separates them is what the stop costs, and that is the detour. Ties
|
||||
* go to the later one, because filling up early wastes the tank you already paid for.
|
||||
*/
|
||||
export function reachableRefuels(
|
||||
pois: Poi[],
|
||||
line: LatLng[],
|
||||
dryAlongKm: number,
|
||||
{ existing = [] }: { existing?: { lat: number; lng: number }[] } = {},
|
||||
): RefuelCandidate[] {
|
||||
const out: RefuelCandidate[] = []
|
||||
for (const poi of pois) {
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, line)
|
||||
if (!hit) continue
|
||||
// Reached before the tank empties, once the detour out and back is paid for.
|
||||
const spareKm = dryAlongKm - hit.alongKm - hit.offRouteKm * 2 - RESERVE_KM
|
||||
if (spareKm < 0) continue
|
||||
// Already planned. Matched on position rather than on id: the same pump added by
|
||||
// hand, or from another source, carries a different osm_id and would otherwise be
|
||||
// suggested next to itself.
|
||||
if (existing.some(e => haversineKm(e, { lat: poi.lat, lng: poi.lng }) < 0.15)) continue
|
||||
out.push({ ...poi, alongKm: hit.alongKm, offRouteKm: hit.offRouteKm, spareKm })
|
||||
}
|
||||
return oneEach(out.sort((a, b) => a.offRouteKm - b.offRouteKm || a.spareKm - b.spareKm))
|
||||
}
|
||||
|
||||
/**
|
||||
* What a search actually established, as opposed to what it returned.
|
||||
*
|
||||
* An empty list means one of two very different things, and saying the wrong one is
|
||||
* worse than saying nothing: either there is no filling station on this stretch, or we
|
||||
* never managed to look. The server flags the second case itself (`truncated` when it
|
||||
* hit its own ceiling, `clamped` when it narrowed the area), and a failed request is the
|
||||
* third. Only `none` is a statement about the road.
|
||||
*/
|
||||
export type RefuelOutcome = 'found' | 'none' | 'incomplete' | 'failed'
|
||||
|
||||
export function outcomeOf(
|
||||
candidates: RefuelCandidate[],
|
||||
answer: { truncated?: boolean; clamped?: boolean } | null,
|
||||
): RefuelOutcome {
|
||||
if (!answer) return 'failed'
|
||||
if (candidates.length) return 'found'
|
||||
// `truncated` alone. `clamped` only means the server searched a smaller circle than
|
||||
// the box described, which the caller now sizes so it never happens — and reporting it
|
||||
// as "we could not check" turned every empty answer into a shrug.
|
||||
return answer.truncated ? 'incomplete' : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per filling station, not one per pump.
|
||||
*
|
||||
* A charging site is mapped in OSM as a node per socket and a forecourt often as one per
|
||||
* pump, all with the same name a few metres apart. Deduplicating by id keeps every one of
|
||||
* them, which is right on a map and wrong in a list of three offers: measured on a real
|
||||
* day, the top three were the same "autostrom plus GmbH" three times over.
|
||||
*
|
||||
* Same name and within half a kilometre counts as the same place. Both halves matter:
|
||||
* not position alone, because two operators do share a motorway services, and not name
|
||||
* alone, because a chain has a branch in every town.
|
||||
*
|
||||
* Half a kilometre comes from the data rather than from taste. In one 25 km search the
|
||||
* four nodes of one charging site sat in two pairs, 6 m and 7 m apart, with 450 m between
|
||||
* the pairs — while genuinely different branches of the same chain were 18 to 36 km
|
||||
* apart. Anything from 500 m to a few kilometres separates those two cases; the lower end
|
||||
* is the safer place to stand.
|
||||
*/
|
||||
const SAME_PLACE_KM = 0.5
|
||||
|
||||
function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] {
|
||||
const kept: RefuelCandidate[] = []
|
||||
for (const poi of sorted) {
|
||||
const twin = kept.some(k =>
|
||||
k.name === poi.name && haversineKm(k, { lat: poi.lat, lng: poi.lng }) < SAME_PLACE_KM)
|
||||
if (!twin) kept.push(poi)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseClock,
|
||||
splitIntoRuns,
|
||||
sumLegSeconds,
|
||||
refuelStopTypeFor,
|
||||
} from './roadtripModel'
|
||||
|
||||
describe('formatDurationShort', () => {
|
||||
@@ -89,7 +90,10 @@ describe('computeSchedule', () => {
|
||||
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
|
||||
})
|
||||
|
||||
it('leaves stops before the first pinned time blank rather than inventing one', () => {
|
||||
it('works back from the first pinned time to say when to set off', () => {
|
||||
// The museum opens at ten and we want an hour at the stop before it, an hour of
|
||||
// driving in between: be there at eight. Answering that is the reason to pin a time
|
||||
// on the second stop at all.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 60 },
|
||||
@@ -97,10 +101,41 @@ describe('computeSchedule', () => {
|
||||
],
|
||||
[hours(1)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
|
||||
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false })
|
||||
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
|
||||
})
|
||||
|
||||
it('stops working back at a leg that never routed rather than inventing one', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 60 },
|
||||
{ anchor: null, dwellMinutes: 30 },
|
||||
{ anchor: '10:00', dwellMinutes: 30 },
|
||||
],
|
||||
[undefined, hours(1)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
|
||||
// Leaves at 09:00 to arrive at 10:00, and its own half hour puts it there at 08:30.
|
||||
expect(entries[1]).toMatchObject({ arrival: '08:30', departure: '09:00' })
|
||||
expect(entries[2]).toMatchObject({ arrival: '10:00', anchored: true })
|
||||
})
|
||||
|
||||
it('keeps the earliest stop on day zero when working back crosses midnight', () => {
|
||||
// Pinned at one in the morning with three hours of driving before it: the stop before
|
||||
// is the evening before. The chain shifts up a day rather than printing a day below
|
||||
// zero, so the boundary shows between the two stops instead of under the first one.
|
||||
const { entries, warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
{ anchor: '01:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(3)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: '22:00', dayOffset: 0 })
|
||||
expect(entries[1]).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true })
|
||||
expect(warnings).toContainEqual({ index: 1, code: 'overnight' })
|
||||
})
|
||||
|
||||
it('restarts the cascade at a pinned stop instead of pushing it', () => {
|
||||
// The drive would arrive at 09:00, but the museum ticket says 11:00: everything
|
||||
// after it counts from 11:00, not from the drive.
|
||||
@@ -545,3 +580,234 @@ describe('deriveDriveWarnings — what counts as driving', () => {
|
||||
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings — where the tank actually runs dry', () => {
|
||||
const drive = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
|
||||
const ferry = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-089: the dry point sits where the fuel ends, not where somebody notices', () => {
|
||||
// 300 km of range, two legs of 200. The warning lands on stop 2, which is 400 km in;
|
||||
// the tank was empty 100 km earlier, halfway through the second leg. Suggesting a
|
||||
// filling station at the warning would suggest one the car cannot reach.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(120, 200), drive(120, 200)],
|
||||
[false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }])
|
||||
expect(out.emptyAt).toEqual([
|
||||
{ legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-090: one dry point per tank, however many warnings the stretch produces', () => {
|
||||
// A long run with nothing on it warns at every stop on purpose, because a warning is
|
||||
// not a fill-up. One refuel offer per warning would stack three identical offers for
|
||||
// a single tank down one day.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 400), drive(60, 400), drive(60, 400)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toHaveLength(2)
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, drivenMeters: 600000 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-091: filling up starts a new tank, and the next one runs dry again', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 400), drive(60, 400), drive(60, 400)],
|
||||
[false, false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
// Empty once before the fuel stop, then once more after it.
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0].drivenMeters).toBe(600000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-092: a ferry carries the car without burning a drop', () => {
|
||||
// The trap this field exists for. The budget skips a ferry, so the dry point must be
|
||||
// counted in DRIVING metres only; measuring along the day's drawn line instead would
|
||||
// overshoot by the whole crossing and put the marker out at sea.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 200), ferry(120, 50), drive(60, 200)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(out.emptyAt).toEqual([
|
||||
{ legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-093: a tank carried over midnight runs dry earlier the next day', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 200), drive(60, 200)],
|
||||
[false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
250,
|
||||
)
|
||||
// Only 50 km left on arrival, so it empties a quarter into the first leg.
|
||||
expect(out.emptyAt).toEqual([
|
||||
{ legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-094: no range limit and no crossing produce no dry point at all', () => {
|
||||
const noLimit = deriveDriveWarnings(
|
||||
[drive(60, 900)],
|
||||
[false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(noLimit.emptyAt).toEqual([])
|
||||
|
||||
const withinRange = deriveDriveWarnings(
|
||||
[drive(60, 100)],
|
||||
[false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(withinRange.emptyAt).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-095: an unrouted leg gives the tank up rather than guessing where it ends', () => {
|
||||
// The budget goes null and stays null, so there is no honest dry point to offer.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 200), undefined, drive(60, 400)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 300 },
|
||||
0,
|
||||
)
|
||||
expect(out.emptyAt).toEqual([])
|
||||
expect(out.carryKm).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refuelsRange — what fills which tank', () => {
|
||||
it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => {
|
||||
// What TREK did before the setting existed, and the right answer for somebody who
|
||||
// never opened the dialog. Anything else would quietly change their warnings.
|
||||
expect(refuelsRange('fuel')).toBe(true)
|
||||
expect(refuelsRange('charging')).toBe(true)
|
||||
expect(refuelsRange('fuel', null)).toBe(true)
|
||||
expect(refuelsRange('charging', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => {
|
||||
// The bug this exists for: an electric car pausing at a petrol station had its
|
||||
// battery refilled on paper, the warnings went quiet for the rest of the day, and
|
||||
// the driver was told nothing.
|
||||
expect(refuelsRange('charging', 'electric')).toBe(true)
|
||||
expect(refuelsRange('fuel', 'electric')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => {
|
||||
expect(refuelsRange('fuel', 'combustion')).toBe(true)
|
||||
expect(refuelsRange('charging', 'combustion')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => {
|
||||
for (const vehicle of [null, 'combustion', 'electric'] as const) {
|
||||
expect(refuelsRange('rest_area', vehicle)).toBe(false)
|
||||
expect(refuelsRange('restaurant', vehicle)).toBe(false)
|
||||
expect(refuelsRange(null, vehicle)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => {
|
||||
expect(refuelStopTypeFor('combustion')).toEqual(['fuel'])
|
||||
expect(refuelStopTypeFor('electric')).toEqual(['charging'])
|
||||
expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings — filling only part way', () => {
|
||||
const drive = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-101: a stop that fills to 80 % leaves a fifth already used', () => {
|
||||
// Nobody charges to 100 % on the road: the last fifth takes as long as the first
|
||||
// four. Counting a stop as a full tank overstates what comes after it by that fifth.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
)
|
||||
// After the stop the budget restarts at 100 km rather than 0, so the tank is dry
|
||||
// 400 km into the second leg. Filled all the way it would have gone the whole 450.
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-102: filling all the way is what absent, zero and 100 all mean', () => {
|
||||
const legs = [drive(60, 100), drive(60, 450)]
|
||||
const refuels = [false, true, false]
|
||||
const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 }
|
||||
for (const fillPercent of [undefined, null, 0, 100]) {
|
||||
const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0)
|
||||
// A full tank after the stop covers the remaining 450 km without a finding.
|
||||
expect(out.emptyAt).toEqual([])
|
||||
expect(out.warnings).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-103: with no range set, the fill level changes nothing', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 900)],
|
||||
[true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([])
|
||||
expect(out.emptyAt).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-104: a stop that says how full it fills is read against itself', () => {
|
||||
// The motorway charger tops up to 60 %; the traveller's own default is 80. Reading
|
||||
// the default here would promise 100 km the car does not have.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
[null, 60, null],
|
||||
)
|
||||
// 60 % of 500 km is 300, so the tank is dry 300 km into the second leg rather than
|
||||
// the 400 the traveller's own figure would have given.
|
||||
expect(out.emptyAt).toHaveLength(1)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 300 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-105: a stop with no opinion still follows the traveller', () => {
|
||||
// Same day, same stop, nothing said about it: the default has to keep applying, or
|
||||
// adding the field would have quietly changed every trip that never used it.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
[null, null, null],
|
||||
)
|
||||
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-106: a stop can also say it fills right up', () => {
|
||||
// The charger at the hotel: the car stands there all night, so this one goes to 100
|
||||
// even on a trip whose default stops at 80.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 100), drive(60, 450)],
|
||||
[false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
|
||||
0,
|
||||
[null, 100, null],
|
||||
)
|
||||
expect(out.emptyAt).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
import type { RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
@@ -121,9 +123,32 @@ export interface ScheduleWarning {
|
||||
*/
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
/** Whether stopping here starts the range budget over. */
|
||||
export function refuelsRange(stopType: string | null | undefined): boolean {
|
||||
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
|
||||
/**
|
||||
* What the traveller drives, which decides what actually fills the tank.
|
||||
*
|
||||
* Absent means both count, which is what TREK did before the setting existed and is the
|
||||
* right answer for somebody who never said. It is only wrong once a vehicle IS named:
|
||||
* a petrol station does not charge a battery, and a charger does not fill a tank.
|
||||
*/
|
||||
export type VehicleKind = 'combustion' | 'electric'
|
||||
|
||||
/** The stop kind that refills the named vehicle, for filtering a search. */
|
||||
export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] {
|
||||
if (vehicle === 'combustion') return ['fuel']
|
||||
if (vehicle === 'electric') return ['charging']
|
||||
return REFUELLING_STOP_TYPES
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether stopping here starts the range budget over.
|
||||
*
|
||||
* With no vehicle named, either kind does — a plain reading of "I filled up". Once one is
|
||||
* named the other stops counting, because the alternative is arithmetic that is simply
|
||||
* wrong: an electric car pausing at a petrol station had its battery refilled on paper,
|
||||
* the warnings went quiet for the rest of the day, and the driver was told nothing.
|
||||
*/
|
||||
export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean {
|
||||
return refuelStopTypeFor(vehicle).includes(stopType ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +156,15 @@ export function refuelsRange(stopType: string | null | undefined): boolean {
|
||||
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
|
||||
*/
|
||||
export interface DriveLimits {
|
||||
/**
|
||||
* How full a fill-up actually goes, 1 to 100, or null for "all the way".
|
||||
*
|
||||
* Nobody charges an electric car to 100 % on the road: the last fifth takes as long as
|
||||
* the first four and the manual says not to. Treating every stop as a full tank
|
||||
* overstates the range after it by exactly that fifth, which is a whole leg on a long
|
||||
* day.
|
||||
*/
|
||||
fillPercent?: number | null
|
||||
/** Longest single drive between two stops, in minutes. */
|
||||
legMinutes: number | null
|
||||
/** Longest total driving in one day, in minutes. */
|
||||
@@ -212,8 +246,10 @@ function resolveArrival(
|
||||
}
|
||||
|
||||
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
|
||||
const entries: ScheduleEntry[] = []
|
||||
const warnings: ScheduleWarning[] = []
|
||||
// Arrivals in minutes, so the chain can be walked twice before anything is formatted.
|
||||
const arrivals: (number | null)[] = new Array(stops.length).fill(null)
|
||||
const anchored: boolean[] = new Array(stops.length).fill(false)
|
||||
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
|
||||
let cursor: number | null = null
|
||||
let dayOffset = 0
|
||||
@@ -224,28 +260,57 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
|
||||
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
|
||||
|
||||
if (arrival === null) {
|
||||
if (arrival === null) continue
|
||||
|
||||
const offset = Math.floor(arrival / DAY_MINUTES)
|
||||
if (offset > dayOffset) dayOffset = offset
|
||||
|
||||
arrivals[i] = arrival
|
||||
anchored[i] = anchor !== null
|
||||
|
||||
const leg = legSeconds[i]
|
||||
cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60)
|
||||
}
|
||||
|
||||
// Then backwards, for the stops the forward walk left blank because nobody had pinned
|
||||
// a time yet. Pinning a time on the second stop is the ordinary way to plan: the museum
|
||||
// opens at ten, so when do we have to leave? Working back from the first known arrival
|
||||
// answers it — departure = the next arrival minus the drive, arrival = that minus the
|
||||
// stay — and it stops at the first leg that never routed rather than inventing one.
|
||||
const firstKnown = arrivals.findIndex(a => a !== null)
|
||||
for (let i = firstKnown - 1; i >= 0; i--) {
|
||||
const leg = legSeconds[i]
|
||||
const next = arrivals[i + 1]
|
||||
if (leg === undefined || next === null) break
|
||||
arrivals[i] = next - Math.round(leg / 60) - (stops[i].dwellMinutes ?? 0)
|
||||
}
|
||||
|
||||
// Working back can land before the anchor day's midnight, which would print as a
|
||||
// negative day. `dayOffset` counts days past the FIRST stop, so the whole chain shifts
|
||||
// up instead until the earliest stop sits on day zero again.
|
||||
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null)
|
||||
const shift = earliest === null || earliest >= 0 ? 0 : -Math.floor(earliest / DAY_MINUTES) * DAY_MINUTES
|
||||
|
||||
const entries: ScheduleEntry[] = []
|
||||
let lastOffset = 0
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const raw = arrivals[i]
|
||||
if (raw === null) {
|
||||
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
|
||||
continue
|
||||
}
|
||||
|
||||
const offset = Math.floor(arrival / (24 * 60))
|
||||
if (offset > dayOffset) {
|
||||
dayOffset = offset
|
||||
warnings.push({ index: i, code: 'overnight' })
|
||||
}
|
||||
|
||||
const dwell = stop.dwellMinutes ?? 0
|
||||
const departure = arrival + dwell
|
||||
const arrival = raw + shift
|
||||
const offset = Math.floor(arrival / DAY_MINUTES)
|
||||
// Read off the finished chain rather than during the forward walk, so a midnight the
|
||||
// backward pass introduced is marked too.
|
||||
if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' })
|
||||
lastOffset = offset
|
||||
entries.push({
|
||||
arrival: formatClock(arrival),
|
||||
departure: formatClock(departure),
|
||||
anchored: anchor !== null,
|
||||
departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)),
|
||||
anchored: anchored[i],
|
||||
dayOffset: offset,
|
||||
})
|
||||
|
||||
const leg = legSeconds[i]
|
||||
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
|
||||
}
|
||||
|
||||
return { entries, warnings }
|
||||
@@ -308,6 +373,29 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
* town. A point past the end lands in the last leg rather than nowhere, because that is
|
||||
* a rounding edge and not a missing leg.
|
||||
*/
|
||||
/**
|
||||
* Where a tank runs dry, in the day's own driving coordinates.
|
||||
*
|
||||
* A separate thing from the range WARNING, and the difference is the whole point. The
|
||||
* warning is filed against the stop the car arrives at, which is where somebody finds
|
||||
* out; this is where the fuel actually runs out, which can be most of a leg earlier and
|
||||
* is the only place worth suggesting a filling station.
|
||||
*
|
||||
* `drivenMeters` counts DRIVING legs only, so it is not an offset into the day's drawn
|
||||
* line: that line also carries ferry and walking runs. Converting it to a coordinate
|
||||
* means walking the driving legs, which `dryPointOn` does.
|
||||
*/
|
||||
export interface DryPoint {
|
||||
/** Index into the day's legs, i.e. the leg the car is on when the tank empties. */
|
||||
legIndex: number
|
||||
/** How far into that leg, in kilometres. */
|
||||
intoLegKm: number
|
||||
/** Metres of driving from the start of the day to that point. */
|
||||
drivenMeters: number
|
||||
/** The range limit that was crossed, which is what the traveller set. */
|
||||
sinceKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The findings about the driving itself: too long at the wheel, too long in one day, and
|
||||
* the tank running out before anywhere to fill it.
|
||||
@@ -344,13 +432,45 @@ export function deriveDriveWarnings(
|
||||
limits: DriveLimits,
|
||||
/** Kilometres already on the tank when the day starts; null when that is unknown. */
|
||||
carryKm: number | null,
|
||||
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
|
||||
/**
|
||||
* Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`.
|
||||
*
|
||||
* A property of the stop rather than of the traveller: a motorway rapid charger is
|
||||
* worth about 80 % because the last fifth costs as long again, while the one at the
|
||||
* hotel is worth all of it because the car stands there all night. One figure for the
|
||||
* whole trip cannot say both, and the difference between them is a leg.
|
||||
*
|
||||
* Last and optional so the four-argument call, which is every caller that has no
|
||||
* per-stop opinion, keeps meaning exactly what it did.
|
||||
*/
|
||||
fillAt: readonly (number | null | undefined)[] = [],
|
||||
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } {
|
||||
const warnings: ScheduleWarning[] = []
|
||||
const emptyAt: DryPoint[] = []
|
||||
let budget = carryKm
|
||||
let totalSeconds = 0
|
||||
// Metres of DRIVING covered so far. Not the same as metres along the day's line: the
|
||||
// line carries every run, and a ferry or a walk in the middle of a day adds to it
|
||||
// without adding to this. Whatever converts a dry point back into a coordinate has to
|
||||
// walk the driving legs only, which is why this is counted here rather than derived.
|
||||
let drivenMeters = 0
|
||||
|
||||
/**
|
||||
* What is already on the clock the moment the car pulls away from stop `i`.
|
||||
*
|
||||
* A tank filled to 80 % has used a fifth of its range before it moves, which is how a
|
||||
* budget that counts upwards says "not full". Zero, absent and 100 all mean it filled
|
||||
* right up, so all three land on nothing used.
|
||||
*/
|
||||
const usedAfterFilling = (i: number): number => {
|
||||
const percent = fillAt[i] ?? limits.fillPercent
|
||||
return limits.rangeKm && percent && percent > 0 && percent < 100
|
||||
? limits.rangeKm * (1 - percent / 100)
|
||||
: 0
|
||||
}
|
||||
|
||||
for (let i = 0; i < legs.length; i++) {
|
||||
if (refuelsAt[i]) budget = 0
|
||||
if (refuelsAt[i]) budget = usedAfterFilling(i)
|
||||
const leg = legs[i]
|
||||
// The stop this leg arrives at. Both findings are about what is true on arrival.
|
||||
const at = i + 1
|
||||
@@ -370,7 +490,25 @@ export function deriveDriveWarnings(
|
||||
if (typeof metres !== 'number') {
|
||||
budget = null
|
||||
} else if (budget !== null) {
|
||||
const before = budget
|
||||
budget += metres / 1000
|
||||
// Where the tank actually runs dry, as opposed to where somebody notices. The
|
||||
// warning below sits on the arriving stop, which can be a hundred kilometres past
|
||||
// the point the fuel ran out; a suggestion has to be offered at the point, or it
|
||||
// suggests filling up somewhere the car cannot reach.
|
||||
//
|
||||
// Once per tank, not once per warning. A long stretch with nothing on it produces
|
||||
// a run of warnings on purpose (see the note above), and one refuel offer per
|
||||
// warning would stack three identical offers down one day for a single fill-up.
|
||||
if (limits.rangeKm && before <= limits.rangeKm && budget > limits.rangeKm) {
|
||||
const intoLegKm = limits.rangeKm - before
|
||||
emptyAt.push({
|
||||
legIndex: i,
|
||||
intoLegKm,
|
||||
drivenMeters: drivenMeters + intoLegKm * 1000,
|
||||
sinceKm: Math.round(limits.rangeKm),
|
||||
})
|
||||
}
|
||||
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
|
||||
// the plan working, not a problem, and a warning there would sit on the one stop
|
||||
// that answers it. No reset either — a warning is not a fill-up, and the figure has
|
||||
@@ -378,18 +516,19 @@ export function deriveDriveWarnings(
|
||||
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
|
||||
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
|
||||
}
|
||||
drivenMeters += metres
|
||||
}
|
||||
}
|
||||
// The last stop of the day counts too: filling up on arrival is what makes the next
|
||||
// morning start with a full tank.
|
||||
if (refuelsAt[legs.length]) budget = 0
|
||||
if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length)
|
||||
|
||||
const minutes = Math.round(totalSeconds / 60)
|
||||
const day = limits.dayMinutes && minutes > limits.dayMinutes
|
||||
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
|
||||
: null
|
||||
|
||||
return { warnings, day, carryKm: budget }
|
||||
return { warnings, day, carryKm: budget, emptyAt }
|
||||
}
|
||||
|
||||
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
|
||||
@@ -639,3 +778,26 @@ export function reanchorAfterReorder(
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The road classes a stored setting asks to leave out, validated.
|
||||
*
|
||||
* Parsed rather than trusted. A per-user setting has no server-side validation at all —
|
||||
* the write route stores any key with any value and says so — so an unknown word here
|
||||
* would travel straight into a costing option the router does not have. Unknown entries
|
||||
* are dropped, the order is fixed so two equal settings produce one cache key, and
|
||||
* anything that is not a non-empty list reads as "route normally".
|
||||
*/
|
||||
export function parseAvoid(raw: unknown): RouteAvoidClass[] {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||
const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase()))
|
||||
return AVOIDABLE.filter(cls => asked.has(cls))
|
||||
}
|
||||
|
||||
/** Every class that can be avoided, in the order they are offered and stored. */
|
||||
export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']
|
||||
|
||||
/** The setting value for a set of classes, in the fixed order. */
|
||||
export function serializeAvoid(classes: readonly RouteAvoidClass[]): string {
|
||||
return AVOIDABLE.filter(cls => classes.includes(cls)).join(',')
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 7, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
|
||||
@@ -41,7 +41,7 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user