Compare commits

..

43 Commits

Author SHA1 Message Date
Maurice e225548c79 docs(tiles): add the 4.3.0 release banner
The 4.3.0 notes drop the plates and the tile index for the Keep a Changelog layout. This banner is the one image left, at the top. Scaled from 5292 to 2400 px wide so the body does not pull 4.7 MB.
2026-09-19 11:43:56 +02:00
Maurice e5bda89faa docs(tiles): give the index heading its own weight 2026-09-17 21:39:20 +02:00
Maurice 815020dd58 docs(tiles): tighten the space between index rows 2026-09-17 21:38:51 +02:00
Maurice ebfa266ee0 docs(tiles): flag the three headliners instead of enlarging them 2026-09-17 21:37:40 +02:00
Maurice f450208db4 docs(tiles): give the release's three headliners their own weight 2026-09-17 21:35:23 +02:00
Maurice d1d7857363 docs(tiles): close the gap under the index band 2026-09-17 21:33:42 +02:00
Maurice e7a5acbd68 docs(tiles): a header band over the release index 2026-09-17 21:32:25 +02:00
Maurice e02cc7ecf7 docs(tiles): carry the gap inside each index plate 2026-09-17 21:29:28 +02:00
Maurice d3219af062 docs(tiles): the release index as twelve small plates 2026-09-17 18:06:07 +02:00
Maurice ae2d522890 docs(tiles): a transparent spacer, so the release index can hold its columns 2026-09-17 18:03:45 +02:00
Maurice b513dde39e docs(tiles): plates for the road trip and Dawarich sections 2026-09-17 17:50:35 +02:00
Maurice dee2603d42 docs(tiles): give both plates a lit glass rim 2026-09-17 17:36:23 +02:00
Maurice 633a2b38a4 docs(tiles): two plates for 4.3.0, on the product's own dark palette 2026-09-17 17:35:01 +02:00
Maurice 2a66665935 docs(tiles): draw the road trip day as a rail, not a list
The card beside the headline was three places stacked, which is what the
planner already had before this addon existed. It is the rail now: one
line down the drive, a filled node for a place you are going to and a
hollow one for a halt on the way, the time and the distance on the line
between them, and the one thing that has to be seen before it is read,
a stop past the daily limit, in the only colour on the plate that is not
its own pair.
2026-09-13 01:16:04 +02:00
Maurice d2bc4cc297 docs(tiles): fill the Dawarich list out to its neighbour's height
Two stays beside a tall pane left a gap under the card that read as a
rendering fault rather than as space. Four fill it, and the list stretches
instead of centring.

The fourth figure was the nine scopes an API key can be narrowed to, and
the section of the release that explained them is gone; it says what the
page still says, that the addon is off until an admin turns it on.
2026-09-13 01:08:29 +02:00
Maurice 4c65b17a72 docs(tiles): let the hero lede run, and give the bars title room
The lede stopped at a width picked by hand, which broke the line early
and left a wedge of empty pane beside it; it runs to the pane's own
padding now, the same gap it keeps on the left. The bars title sits on
the ground rather than in a pane, so it takes more air under it than two
panes take between them.
2026-09-13 01:07:15 +02:00
Maurice 2e1db584bd docs(tiles): the bars title comes off the pane
It sat in a glass panel of its own with nothing else in it, which read as
a card that had lost its contents. It is the plate's heading now, on the
ground at the plate's own margin, the way the eyebrows sit elsewhere.
The winning row drops "alternating"; the bar beside it already says what
the two sources add up to.
2026-09-13 01:05:42 +02:00
Maurice 003c5826ab docs(tiles): the bars plate carries its title alone
The paragraph under it explained the corpus, which the sentence beside
the image on the release page already does. The plate is the claim and
the four bars behind it.
2026-09-13 01:03:43 +02:00
Maurice dff79089ab docs(tiles): road trip plates, and edits to the Places API set
Three plates for the road trip addon in a third palette, violet and
teal, so the page's three subjects tell themselves apart before a word is
read: the drive as one chain with a day's own card beside it, what it
finds along the road against what a list of days could offer, and the
four ways to send the drive where you want it.

The Places API set loses two headings that repeated the sentence above
them, and the half of each comparison that is history is drained of
colour and dimmed, by brightness rather than opacity so the pane keeps
hiding the route behind it. "Index" is gone everywhere in favour of TREK
API, and the bars drop the index-alone row.
2026-09-13 01:03:03 +02:00
Maurice f9dabccf19 docs(tiles): warm palette for the Places API half
Both halves of the release page were built on the same blue and green,
which are Dawarich's own two brand colours and mean something on its
plates. On the Places API ones they meant nothing and made the page read
as one long subject.

The aurora and the accents are four tokens now, overridden per plate and
nothing else with them: the Places API set is amber and rose, Dawarich
keeps the cool pair it takes from its mark.
2026-09-13 00:52:41 +02:00
Maurice f84564e2e9 docs(tiles): use the real trek API lockup in the hero
The mark was assembled here out of the plain wordmark and a hand-built
API chip, which is not the lockup: the official one has no leading glyph
and its own badge geometry. It ships as one file now, black on
transparent as delivered, inverted on the plate because that is the
dark-background variant.
2026-09-13 00:49:57 +02:00
Maurice 105cce5deb docs(tiles): put the API mark level with the headline
The plate is 16:5 and its right half was empty ground while the lockup
stacked above a two-line title and pushed the lede down the card. The
mark sits on the far side of the headline now, at the size that space
deserves.

The fit check was reading .stage, which is `height: 100%` and therefore
always reported the plate's own height; it measures the first and last
block instead, which is how the hero turns out to have been carrying 94px
of empty ground under it.
2026-09-13 00:45:46 +02:00
Maurice be5004e282 docs(tiles): one spacing step for every plate, and fit the heights
The gaps were whatever each plate happened to pick: 16 here, 22 there,
and a `margin-top: auto` that pushed a tile row flush against the card
above it. They are one token now, used between the stacked blocks,
between the tiles, and between a card and the numbers under it.

Every plate is also as tall as its content instead of a guess, so nothing
is clipped and no plate publishes a band of empty ground. The Places API
hero drops the release pill and carries its API badge at the size the
lockup is now, and one subtext in the comparison is a line shorter so it
stops wrapping.
2026-09-13 00:43:36 +02:00
Maurice b37d66a4c1 docs(tiles): redraw the Places API plates to match, and glass the rims
The release page carried two visual languages: flat cards for the Places
API and glass for Dawarich, one above the other. All five plates are the
same build now, from the same stylesheet, with the wording, the figures,
the bars and the row order of the originals left exactly as they were.

Each plate also carries the rim it was missing, a lit 1px edge and a
sheen along the top, so the image reads as a pane rather than as a dark
rectangle with rounded corners.
2026-09-13 00:39:56 +02:00
Maurice c620b44a7b docs(tiles): give both marks the same badge in the hero lockup
TREK's wordmark sat bare beside a chipped Dawarich, which read as one
brand hosting another rather than two meeting. Both are in the same glass
pill now, at a size the lockup deserves, with the multiplication sign
white between them instead of grey.
2026-09-13 00:30:36 +02:00
Maurice c58818e8f8 docs(tiles): round the Dawarich plates off and lift the small type
The plates are rounded now and carry transparency outside the radius, so
they sit on the release page as cards rather than as two black rectangles
with square corners against it.

The secondary lines were the same grey as the labels around them and read
as switched off at the size GitHub renders these; they are close to white
now. The second plate loses its heading, which repeated the sentence
above it, and the route line is one line instead of two.
2026-09-13 00:28:50 +02:00
Maurice 5e1b588791 docs(tiles): redraw the Dawarich plates in glass, and cut to two
The flat cards read as a spec sheet. Both plates now sit on the same
aurora, mesh and recorded route the app's own glass panels imply, with
rounded frosted panes over it; the hero carries a real From Dawarich row
so the first thing anybody sees is the product rather than a claim.

Two plates, not three: the third said in pictures what the text beside it
already says in words.
2026-09-13 00:21:49 +02:00
Maurice b4ea23203d docs(tiles): release art for the Dawarich integration
Three plates in the same dark palette and type scale as the Places API
set, built from the tokens in client/src/index.css, the TREK wordmark and
Dawarich's own mark: the lockup and the four numbers that matter, the
line between what stays in Dawarich and what reaches TREK as a question,
and the four places it turns up once it is connected.
2026-09-13 00:16:48 +02:00
Maurice b9d0f6df4b docs(tiles): close the gap the corpus note left behind
Blanking the two lines left a band of empty plate between the title and the
first bar, which read as a missing element rather than as breathing room.
The band is cut out instead, so the plate is 56px shorter and the spacing
above the bars matches the spacing below them.
2026-09-08 22:12:12 +02:00
Maurice aa482be840 docs(tiles): drop the corpus note under the top-5 title
The two grey lines explained where the 128 places came from. In a release
note that reads as a caveat before the numbers rather than context after
them, and the corpus is already described in the text around the image.
2026-09-08 22:10:57 +02:00
Maurice afa0dd0e33 docs(tiles): the two figures that got better read as such
72.7% and 58 ms take the success token, and their boxes a trace of it.
Only those two: tinting the licence line underneath as well would turn a
statement of fact into a claim.
2026-09-07 22:30:14 +02:00
Maurice 4be4c4ea11 docs(tiles): drop the eyebrow over the bars
The caption under the heading already says where the numbers come from.
2026-09-07 22:26:04 +02:00
Maurice 5db38e82f2 docs(tiles): a glass edge on every plate
The same treatment the repo's own tiles carry: a hairline ring at 12%
white, a brighter line along the top where the light would catch, and a
diagonal sheen falling off from the top left. The radius matches the
mask the PNG is cut with, so the ring follows the corners instead of
stopping at them.
2026-09-07 22:24:28 +02:00
Maurice 8cbea7ad47 docs(tiles): drop the eyebrow over the comparison
The heading above it in the post says the same thing.
2026-09-07 22:20:51 +02:00
Maurice 49c598a384 docs(tiles): close the gap under the hero subtext
The pills were pushed to the bottom of the plate, which left a hand's
width of nothing between them and the sentence they belong to. The plate
is as tall as its content now.
2026-09-07 21:21:27 +02:00
Maurice 1dd1ad838d docs(tiles): the hero field carries itself
The dashed drive and its five lit stops were one idea too many over a
field that already reads. Just the field now, and the light behind the
mark.
2026-09-07 20:59:27 +02:00
Maurice 7f5352efe5 docs(tiles): drop the route line from the hero
The field carries the plate on its own; the dashed drive across it was
one idea too many.
2026-09-07 20:58:46 +02:00
Maurice 96f5d9b587 docs(tiles): give the hero a field of places to stand in
73.6 million is a number nobody can picture, so the plate shows a field
instead of only naming one: 520 seeded dots weighted to the right so the
headline keeps a quiet ground, five of them lit and strung together like
a drive across it. The mark lifts off that on its own light.
2026-09-07 20:48:21 +02:00
Maurice 049cc50fda docs(tiles): both plates end on the same line
min-height only set a floor, and the right card's two wrapping notes
pushed past it, so the pair ended seventeen pixels apart. Both fill the
grid row now.
2026-09-07 20:39:06 +02:00
Maurice cd6f3b9d2c docs(tiles): figures in boxes, the old column read as old, rounded plates
The past column now carries a faint diagonal hatch and one step less
contrast on every word in it, so the eye sorts the two sides before it
reads either. Every figure sits in its own box, the three closing
numbers are centred, and the caption above them lost the sentence that
pushed it onto a third line. All four plates get rounded corners with
real transparency, so they sit on either GitHub theme.
2026-09-07 20:36:39 +02:00
Maurice 784237a02b docs(tiles): the wordmark beside the title, and the figures as badges
The subtext ran on across three ragged lines and the four figures sat in
a bare grid under a rule. They are pills now, and the sentence breaks
where it means to. The mark moves out of the corner into a plate beside
the headline, where it is large enough to read as the subject.
2026-09-07 20:26:02 +02:00
Maurice f9333d51a6 docs(tiles): three bars, and the arrow on the panel row
The chart carried a fourth bar for the index on its own, which is a step
in the reasoning rather than an option anybody can pick. Nominatim, what
ships, and Google is the comparison a reader is actually making.

The arrow sat in a column that included the metrics under the panels, so
it centred below them. It sits on the panel row now.
2026-09-07 20:24:41 +02:00
Maurice 802ac51904 docs(tiles): release art for the TREK Places API
Four plates in the TREK dark palette, built from the real tokens in
client/src/index.css, the wordmark component and the Lucide set the app
already uses: the hero, the before-and-after against Nominatim and
Overpass, the settings card at poster size, and the measured top-5 bars.
2026-09-07 20:20:52 +02:00
515 changed files with 2242 additions and 21278 deletions
-27
View File
@@ -387,33 +387,6 @@ 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 })))
+1 -14
View File
@@ -413,22 +413,9 @@ 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' },
+5 -68
View File
@@ -661,6 +661,8 @@ 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),
@@ -1143,50 +1145,6 @@ 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
@@ -1196,25 +1154,10 @@ 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' }), 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}` }
}),
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
withCachedPlaces(
input,
@@ -1441,19 +1384,13 @@ 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 | FormData, opts?: UploadOptions) => data instanceof FormData
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => 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),
-24
View File
@@ -1,24 +0,0 @@
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),
}
-8
View File
@@ -20,9 +20,6 @@ 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',
@@ -30,11 +27,6 @@ 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',
+1 -2
View File
@@ -74,12 +74,11 @@ 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; links?: boolean; polls: boolean; whatsnext: boolean }
interface CollabFeatures { chat: boolean; notes: 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,7 +262,6 @@ 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 { Map as MapIcon, Settings2 } from 'lucide-react'
import { Settings2 } from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -44,7 +44,6 @@ 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
@@ -122,7 +121,6 @@ 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(() => {
@@ -133,7 +131,6 @@ 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))
@@ -157,7 +154,6 @@ 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))
@@ -227,23 +223,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}
return (
<div>
<p className="text-sm text-content-faint mb-4">
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
{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" /></>}>
{([
@@ -341,9 +325,7 @@ 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">
@@ -413,28 +395,6 @@ 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, {
@@ -560,8 +520,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</div>
)}
</div>
</Section>
</div>
</div>
</Section>
)
}
+7 -156
View File
@@ -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, Receipt, Paperclip } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -8,11 +8,10 @@ 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, openFile } from '../../utils/fileDownload'
import { downloadBlob } from '../../utils/fileDownload'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -20,8 +19,7 @@ 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 { ReceiptPreviewModal } from './ReceiptPreviewModal'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
import { NumericInput } from '../shared/NumericInput'
@@ -86,7 +84,6 @@ 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)
@@ -495,14 +492,6 @@ 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);
@@ -773,32 +762,6 @@ 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' }}>
@@ -1159,24 +1122,6 @@ 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'
@@ -1373,23 +1318,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
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([])
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
onSaved()
} 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'))
} catch {
toast.error(t('common.unknownError'))
} finally {
setUploadingReceipt(false)
setSaving(false)
}
}
@@ -1699,91 +1633,8 @@ 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>
)
}
@@ -1,99 +0,0 @@
// 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()
})
})
@@ -1,405 +0,0 @@
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
)
}
@@ -1,91 +0,0 @@
// 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])
})
})
@@ -1,87 +0,0 @@
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
}
}
+6 -11
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'react-dom'
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
import { ArrowUp, 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, 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
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
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 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">
<div 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,8 +48,6 @@ 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 && (
@@ -63,8 +61,6 @@ 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}
@@ -80,16 +76,15 @@ 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() && !imageFiles.length) || sending} style={{
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
@@ -1,10 +0,0 @@
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,7 +3,6 @@ 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'
@@ -167,7 +166,6 @@ 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) }} />
))}
@@ -1,123 +0,0 @@
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,7 +6,6 @@ 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', () => ({
@@ -40,12 +39,11 @@ describe('CollabPanel', () => {
})
// FE-COMP-COLLABPANEL-001
it('desktop layout renders all five panels', () => {
it('desktop layout renders all four 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()
})
@@ -57,7 +55,6 @@ 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
@@ -76,13 +73,6 @@ 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)
@@ -157,7 +147,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, links: false, polls: false, whatsnext: false }
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
describe('CollabPanel feature combinations', () => {
beforeEach(() => {
@@ -182,7 +172,6 @@ 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()
})
@@ -264,42 +253,6 @@ 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} />)
+54 -45
View File
@@ -1,12 +1,11 @@
import { useState, useEffect, useMemo } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
import { MessageCircle, StickyNote, 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)
@@ -29,7 +28,6 @@ interface TripMember {
interface CollabFeatures {
chat: boolean
notes: boolean
links?: boolean
polls: boolean
whatsnext: boolean
}
@@ -41,11 +39,10 @@ interface CollabPanelProps {
}
const ALL_TABS = [
{ 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 },
{ 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 },
]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
@@ -53,14 +50,12 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const { t } = useTranslation()
const isDesktop = useIsDesktop()
// 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 features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
const tabs = useMemo(() =>
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
...tab,
label: t(tab.labelKey) || tab.fallback,
label: t(tab.labelKey),
})),
[features, t])
@@ -75,28 +70,11 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const chatOn = features.chat
const rightPanels = [
features.notes && 'notes', features.links && 'links',
features.notes && 'notes',
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) {
@@ -121,19 +99,35 @@ 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 <= 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)}
{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>
</>
})()}
)}
</div>
</div>
)
@@ -141,10 +135,26 @@ 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 }}>{renderPanel(p)}</div>
<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>
)
@@ -160,7 +170,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{tabs.map(tab => {
const active = mobileTab === tab.id
return (
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
<button type="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',
@@ -177,7 +187,6 @@ 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>
@@ -1,77 +0,0 @@
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 }
}
+9 -25
View File
@@ -1,11 +1,10 @@
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
import { useState, useEffect, useRef, useCallback } 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) {
@@ -24,8 +23,6 @@ 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())
@@ -41,7 +38,6 @@ 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') => {
@@ -121,35 +117,23 @@ 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 && !images.files.length) || sending) return
if (!body || sending) return
setSending(true)
try {
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)
}
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const 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); images.clear()
setText(''); setReplyTo(null); setShowEmoji(false)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
} catch { toast.error(t('common.error')) } finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
@@ -191,5 +175,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, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, 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, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
}
@@ -5,7 +5,6 @@ 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 }
@@ -54,16 +53,7 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
return merged
}
/**
* 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.
*/
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
@@ -82,7 +72,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="min-w-0 flex-1" ref={ref}>
<div className="relative 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>
@@ -90,7 +80,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 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">
<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">
{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'}`}>
@@ -183,10 +173,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
]
return (
// 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">
<aside className="w-full sm:w-64 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>
@@ -202,7 +189,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
/>
</div>
{lists.length > 0 && (
<div className="relative flex gap-2">
<div className="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>
@@ -214,13 +201,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
<Loader2 size={18} className="animate-spin" />
</div>
) : visible.length === 0 ? (
<EmptyState
scene="search"
title={t('collections.picker.empty')}
size={84}
fill
surface="var(--bg-secondary)"
/>
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
) : (
<div className="flex flex-col gap-1">
{page.map(place => (
@@ -1,101 +0,0 @@
// 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,16 +3,6 @@ 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
@@ -20,8 +10,6 @@ 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() }
@@ -39,73 +27,19 @@ 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={toggleOpen}
onClick={() => setOpen(!open)}
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 ? (
@@ -125,86 +59,54 @@ 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]">
{/* Header: arrows step the current view, the label climbs to the next one up */}
{/* Month nav */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
<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">
<ArrowLeft size={14} />
</button>
<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}>
<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">
<ChevronRight size={14} />
</button>
</div>
{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}
{/* 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>
)}
{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>
</div>
</>
)}
@@ -1,4 +1,4 @@
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
// FE-JRN-PICKER-001 to FE-JRN-PICKER-020
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
@@ -324,39 +324,6 @@ 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,244 +1,189 @@
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';
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'
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 [adding, setAdding] = 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 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 {
const assets = (await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [];
setPhotos(assets);
} catch {
/* ignore */
}
if (!signal.aborted) setLoading(false);
};
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).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
? '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'
}
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)]'}
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
? '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'
}
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'}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="presentation"
onClick={(e) => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
{/* Header */}
{!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>
)}
{!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>}
{/* Filter bar */}
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
{/* Tabs */}
<div className="mb-3 flex gap-1.5">
<div className="flex gap-1.5 mb-3">
{[
...(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={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
filter === f.id
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
@@ -247,252 +192,180 @@ export function ProviderPicker({
<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="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">&mdash;</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="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">&mdash;</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 flex-1 items-center gap-2">
<div className="flex-1">
<DatePicker value={customFrom} onChange={setCustomFrom} />
</div>
<span className="text-[12px] text-zinc-400">&mdash;</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"
{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]">&mdash;</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'
}`}
>
{t('journey.picker.search')}
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
</button>
</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>
)}
))}
{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="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'
}`}
>
<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="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>
)}
{/* 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="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'
{!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'
}`}
>
{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>
);
})()}
<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'
}`}
>
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
</button>
))}
</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>
)
})()}
{/* Photo grid */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
{loading ? (
<div className="flex justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
) : sortedPhotos.length === 0 ? (
<div className="py-12 text-center">
<div className="text-center py-12">
<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="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
{group.label}
</p>
)}
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
{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"
@@ -501,47 +374,42 @@ export function ProviderPicker({
aria-pressed={isSelected}
aria-label={asset.city || group.label}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
alreadyAdded
? 'cursor-not-allowed opacity-40'
? 'opacity-40 cursor-not-allowed'
: isSelected
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="h-full w-full object-cover"
className="w-full h-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 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<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">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<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">
<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">
<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 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 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>
)}
</button>
);
)
})}
</div>
</div>
@@ -553,58 +421,35 @@ export function ProviderPicker({
</div>
{/* Footer */}
<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>
<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>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</span>
<div className="flex items-center gap-2">
<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"
>
<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">
{t('common.cancel')}
</button>
<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);
<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)
}
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
onAdd(groups, targetEntryId)
}}
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"
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"
>
{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})` : ''}
</>
)}
{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-009
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-006
import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
@@ -87,44 +87,4 @@ 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,24 +2,13 @@ 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) onVisibleRef.current() }, { rootMargin: '200px' })
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
obs.observe(el)
return () => obs.disconnect()
}, [loading])
}, [onVisible, 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,9 +86,6 @@ 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 -5
View File
@@ -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, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
export interface MapMarkerItem {
@@ -162,8 +162,7 @@ function JourneyMap(
const tileUrlRef = useRef(tileUrl)
tileUrlRef.current = tileUrl
const tileLayerRef = useRef<L.TileLayer | null>(null)
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
const glLayerRef = useRef<BasemapLayer | null>(null)
const glLayerRef = useRef<GlLeafletLayer | 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)
@@ -380,7 +379,7 @@ function JourneyMap(
map.remove()
mapRef.current = null
tileLayerRef.current = null
detachBasemapLayer(glLayerRef.current)
glLayerRef.current?.remove()
glLayerRef.current = null
markersRef.current.clear()
}
@@ -390,7 +389,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)) restyleBasemap(glLayerRef.current, tileUrl)
if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl)
else tileLayerRef.current?.setUrl(tileUrl)
}, [tileUrl])
+3 -22
View File
@@ -25,33 +25,14 @@ describe('buildNavItems', () => {
})
describe('splitMobileNav', () => {
it('docks the top two enabled defaults when there is no config', () => {
it('falls back to Dashboard + first two in the bar when there is no config', () => {
for (const cfg of [undefined, { bar: [], more: [] }]) {
const s = splitMobileNav(items, cfg)
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'journey'])
// Atlas is still reachable, one tap further under More.
expect(ids(s.more)).toEqual(['atlas', 'collections'])
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
expect(ids(s.more)).toEqual(['journey', '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'])
+10 -24
View File
@@ -35,18 +35,8 @@ 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, 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']
/** The built-in dock next to Dashboard for an un-customised account; everything else starts under More. */
export const DEFAULT_DOCK_IDS = ['vacay', 'atlas']
export function buildNavItems(
globalAddons: { id: string; name: string; icon: string }[],
@@ -100,10 +90,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 + 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.
* 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.
*/
export function splitMobileNav(
items: NavItemDef[],
@@ -112,20 +102,16 @@ 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, ...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)),
bar: [...head, ...rest.filter((i) => DEFAULT_DOCK_IDS.includes(i.id))],
more: rest.filter((i) => !DEFAULT_DOCK_IDS.includes(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 -2
View File
@@ -1,5 +1,4 @@
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'
@@ -78,7 +77,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: MAP_CONTROL_SHADOW,
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -1,6 +1,5 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -19,7 +18,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
}}>
<button
type="button"
+1 -25
View File
@@ -18,13 +18,7 @@ const mapMock = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
// 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) },
})),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
// 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) }),
@@ -227,24 +221,6 @@ 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()
+3 -11
View File
@@ -606,9 +606,6 @@ 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,
@@ -940,12 +937,7 @@ 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])
// 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
const switcherBottom = hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)'
@@ -1035,7 +1027,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
@@ -1049,7 +1041,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
@@ -1540,21 +1540,6 @@ 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))
@@ -1970,68 +1955,4 @@ 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')
})
})
+9 -144
View File
@@ -8,7 +8,6 @@ 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'
@@ -28,7 +27,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, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -49,21 +48,6 @@ 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 }
@@ -123,11 +107,6 @@ 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
@@ -254,44 +233,6 @@ 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.
@@ -611,7 +552,6 @@ export function MapViewGL({
routeVias = NO_ROUTE_VIAS,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
hoverDisabled = false,
@@ -654,15 +594,6 @@ 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
@@ -768,23 +699,6 @@ 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'
@@ -873,26 +787,8 @@ 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.
*
@@ -1219,9 +1115,7 @@ export function MapViewGL({
id: 'trip-route-casing',
type: 'line',
source: 'trip-route',
// 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 },
paint: { 'line-color': '#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
@@ -1238,7 +1132,7 @@ export function MapViewGL({
id: 'trip-route-line',
type: 'line',
source: 'trip-route',
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 },
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
@@ -1928,18 +1822,13 @@ export function MapViewGL({
if (!map) return
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
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]) },
}))
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]) },
}))
src.setData({ type: 'FeatureCollection', features })
}, [route, routeColors, mapReady])
}, [route, mapReady])
// Update access-spur geojson
useEffect(() => {
@@ -2169,18 +2058,6 @@ 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">
@@ -2216,18 +2093,6 @@ 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-045
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -389,43 +389,6 @@ 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,7 +8,6 @@ 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'
@@ -123,12 +122,6 @@ 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
@@ -207,25 +200,26 @@ 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
// 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)
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
})
}, [items, zoom, map, roadRoutes])
}, [items, zoom, map])
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])
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
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)
}
return set
}, [visibleItems, zoom, map])
@@ -254,7 +248,9 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
return linesFor(item, roadRoutes).map((seg, segIdx) => (
const road = roadRoutes?.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Marker, Tooltip, useMap } from 'react-leaflet'
import React, { useMemo, useRef } from 'react'
import { Marker, Tooltip } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
@@ -31,9 +31,6 @@ 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 {
@@ -47,22 +44,6 @@ 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,
@@ -81,7 +62,7 @@ export default function RoadtripViaMarkers({
},
})), [vias])
if (!markers.length || !zoomedIn) return null
if (!markers.length) return null
return (
<>
@@ -797,119 +797,6 @@ 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
+26 -268
View File
@@ -3,9 +3,6 @@ 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
@@ -53,44 +50,6 @@ 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".
*
@@ -155,7 +114,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 routedFetch(url, signal, 'route', profile, waypoints)
const response = await fetch(url, { signal })
if (!response.ok) {
throw new RoutingRefusedError(response.status, retryAfterMs(response))
}
@@ -362,7 +321,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 routedFetch(url, signal, 'segments', 'driving', waypoints)
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -393,55 +352,23 @@ export async function calculateSegments(
*/
export async function calculateRouteWithLegs(
waypoints: Waypoint[],
{ 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[]
} = {}
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
): 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 avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
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.
@@ -489,7 +416,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 routedFetch(url, signal, 'legs', osrmProfile, waypoints)
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -519,73 +446,12 @@ export async function calculateRouteWithLegs(
const snapped = readSnapped(data, waypoints)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
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)
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
}
/**
* 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)) },
}
return result
}
/**
@@ -648,7 +514,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?: AvoidClass
avoids?: 'motorway' | 'toll'
}
/**
@@ -662,39 +528,11 @@ 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', 'ferry'] as const
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
/**
* 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.
*/
/** Two lines are the same road if their length and their duration agree closely enough. */
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
}
@@ -725,76 +563,16 @@ const excludeUnsupported = new Set<string>()
* particular question had no answer.
*/
async function routeExcluding(
from: Waypoint,
to: Waypoint,
coords: string,
profile: 'driving' | 'walking' | 'cycling',
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,
exclude: string,
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 routedFetch(url, signal, 'alternatives', profile, [from, to])
const response = await fetch(url, { signal })
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.
@@ -836,7 +614,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 routedFetch(url, signal, 'alternatives', profile, [from, to])
const response = await fetch(url, { signal })
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -851,18 +629,19 @@ 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, 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.
// 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.
//
// 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.
// 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.
if (routes.length < 2 && profile === 'driving') {
for (const exclude of EXCLUDABLE_CLASSES) {
if (signal?.aborted) break
const detour = await routeExcluding(from, to, profile, exclude, signal)
const detour = await routeExcluding(coords, profile, exclude, signal)
if (detour && !routes.some(r => sameRoad(r, detour))) {
detour.avoids = exclude
routes.push(detour)
@@ -880,43 +659,22 @@ 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; nearestPoint = reference[j] }
if (d < nearest) nearest = d
}
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
}
if (!best) return null
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { ...best, km }
return best
}
@@ -1,90 +0,0 @@
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()
})
})
@@ -1,130 +0,0 @@
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,17 +10,8 @@ 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 { 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'
import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap'
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults'
const addAttribution = vi.fn()
/** Only the two things the component touches; casting keeps the mock honest about that. */
@@ -29,16 +20,7 @@ const leafletMap = asMap({ attributionControl: { addAttribution } })
/** A map built without an attribution control, like the atlas. */
const bareMap = asMap({})
// 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('react-leaflet', () => ({ useMap: () => leafletMap }))
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
const maplibreGL = vi.fn(() => {
@@ -219,91 +201,3 @@ 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()
})
})
+30 -196
View File
@@ -1,30 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { TileLayer, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useEffect, useRef } from 'react'
import { useMap } from 'react-leaflet'
import type * as L from 'leaflet'
import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet'
import {
RASTER_FALLBACK_MAX_ZOOM,
RASTER_FALLBACK_TILE_URL,
attributionForTile,
} from '../../constants/mapDefaults'
import { hasWebGL } from '../../utils/webgl'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
import { attributionForTile } from '../../constants/mapDefaults'
/** 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.
*
@@ -46,105 +28,11 @@ 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.
*
@@ -152,37 +40,21 @@ export function restyleBasemap(layer: BasemapLayer | null | undefined, style: st
* 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 }) {
export function VectorBasemap({ style }: { style: string }): null {
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 loadLayerFactoryOrNull()
const maplibreGL = await loadLayerFactory()
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
@@ -192,106 +64,68 @@ export function VectorBasemap({ style }: { style: string }) {
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
detachBasemapLayer(layerRef.current)
layerRef.current?.remove()
layerRef.current = null
}
}, [map, glUsable])
}, [map])
// Restyle in place. Swapping the layer would drop a WebGL context per theme
// toggle, and browsers cap those in the low teens.
useEffect(() => {
restyleBasemap(layerRef.current, style)
layerRef.current?.getMaplibreMap()?.setStyle(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 basemap for the maps that build Leaflet imperatively rather than
* The same layer 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. 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.
* 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.
*/
export async function attachVectorBasemap(
map: L.Map,
style: string,
ref: { current: BasemapLayer | null },
ref: { current: GlLeafletLayer | null },
cancelled: () => boolean,
opts: { hideLabels?: boolean } = {},
): Promise<void> {
// 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()
const maplibreGL = await loadLayerFactory()
if (cancelled()) return
if (!maplibreGL) {
ref.current = attachRasterFallback(map)
return
}
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
// 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
layer.addTo(map)
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
// one of its own.
creditBasemap(map, style)
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: 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
export function hideLabelLayers(layer: GlLeafletLayer): void {
const gl = layer.getMaplibreMap()
if (!gl) return
const apply = () => {
@@ -1,108 +0,0 @@
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([])
})
})
-169
View File
@@ -1,169 +0,0 @@
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
}
@@ -1,10 +0,0 @@
/**
* 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,26 +613,6 @@ 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', () => {
+14 -16
View File
@@ -13,7 +13,6 @@ 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'
@@ -260,31 +259,27 @@ 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: 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]])
// 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.
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
return hopIsVisible(item.type, this.linesFor(item), project)
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
} catch { return true }
}) : []
@@ -298,7 +293,8 @@ 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)
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
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)
} catch { /* ignore */ }
}
}
@@ -324,7 +320,9 @@ export class ReservationMapboxOverlay {
}))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
return this.linesFor(item).map(seg => ({
const road = this.roadRoutes.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
@@ -1,88 +0,0 @@
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)
})
})
@@ -1,102 +0,0 @@
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()
}
@@ -1,86 +0,0 @@
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)
})
})
@@ -1,42 +0,0 @@
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
}
@@ -1,139 +0,0 @@
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)
})
})
@@ -1,227 +0,0 @@
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
}
@@ -1,367 +0,0 @@
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()
})
})
-411
View File
@@ -1,411 +0,0 @@
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,8 +95,6 @@ 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
@@ -148,7 +146,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onCreatePlaceForDay,
onNavigateToFiles,
routeShown = false,
routeProfile = 'driving',
@@ -1076,7 +1073,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onCreatePlaceForDay,
onNavigateToFiles,
routeShown,
routeProfile,
@@ -1248,7 +1244,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onCreatePlaceForDay,
onNavigateToFiles,
routeShown,
routeProfile,
@@ -1897,34 +1892,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
}}
>
{/* 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>
)}
<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).
export function profileIcon(profile: string) {
function profileIcon(profile: string) {
if (profile === 'walking') return Footprints
if (profile.startsWith('plugin:')) return Zap
return Car
@@ -467,33 +467,41 @@ describe('PlaceDetailsColumn — hours and rating', () => {
})
})
/**
* FE-PDC-027 — nothing came back for this place.
* FE-PDC-027..029 — the nudge towards a Google key.
*
* 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.
* 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.
*/
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()
describe('PlaceDetailsColumn — no Google key', () => {
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
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-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 the free sources did find something', async () => {
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
empty()
renderColumn({ hasMapsKey: true })
await screen.findByText('places.details.nothing')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
})
it('FE-PDC-029: 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()
renderColumn({ hasMapsKey: false })
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
})
})
/**
@@ -10,11 +10,13 @@ import {
ChevronUp,
Clock,
ExternalLink,
ImageOff,
Landmark,
Leaf,
Loader2,
ScrollText,
ShoppingBag,
Sparkles,
Sprout,
Star,
Sun,
@@ -25,7 +27,6 @@ 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. */
@@ -52,6 +53,7 @@ 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
}
@@ -117,6 +119,7 @@ export default function PlaceDetailsColumn({
language,
timeFormat = '24h',
locale = 'en-US',
hasMapsKey = false,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
@@ -205,17 +208,7 @@ export default function PlaceDetailsColumn({
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5">
{/* 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 && <p className="text-caption text-content-muted">{t('places.details.empty')}</p>}
{selection && state === 'loading' && (
<div className="flex items-center gap-2 text-caption text-content-muted">
@@ -230,20 +223,17 @@ 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 && (
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
<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>
)}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -472,6 +462,19 @@ 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,43 +299,6 @@ 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(
@@ -386,80 +349,10 @@ describe('PlaceFormModal', () => {
delete window.__addToast;
});
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.
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
// hasMapsKey is false by default in beforeEach
render(<PlaceFormModal {...defaultProps} />);
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();
expect(screen.getByText(/OpenStreetMap/i)).toBeInTheDocument();
});
// ── Category ─────────────────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
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'
@@ -18,7 +19,6 @@ 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,32 +56,6 @@ 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. */
@@ -118,11 +92,10 @@ 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
@@ -145,11 +118,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const searchInputRef = useRef<HTMLInputElement>(null)
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 [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
@@ -286,7 +255,6 @@ 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
@@ -347,7 +315,6 @@ 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 {
@@ -404,7 +371,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: Suggestion) => {
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
// 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
@@ -432,21 +399,6 @@ 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)
@@ -617,6 +569,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -636,8 +589,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -691,6 +642,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -710,8 +662,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -793,12 +743,18 @@ 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
@@ -839,15 +795,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
idx === acHighlight ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'
}`}
>
<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>
<div className="font-medium text-sm">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
</button>
))}
</div>
@@ -864,13 +815,8 @@ 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="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>
<div className="font-medium text-sm">{result.name}</div>
<div className="text-xs text-content-muted truncate">{result.address}</div>
</button>
))}
</div>
@@ -968,6 +914,30 @@ 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,39 +88,6 @@ 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();
@@ -288,113 +255,6 @@ 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,19 +43,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
<PlacesSelectionBar {...S} />
) : (
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
{/* 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>
<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>
</div>
)}
@@ -1,18 +1,8 @@
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star } 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={{
@@ -31,118 +21,52 @@ export function PlacesDropOverlay({ t }: SidebarState) {
export function PlacesHeader(S: SidebarState) {
const {
canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
places, categories, categoryFilters, search, setSearch, plannedIds, plannedFilterIds, dayScoped, onClearSelectedDay, hasTracks,
canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
places, categories, categoryFilters, search, setSearch, plannedIds, 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 && <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 && <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 && <>
{/* 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, minWidth: 0, padding: '5px 12px', borderRadius: 8,
flex: 1, 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} /> {!compact && fileImportLabel}
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
</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, minWidth: 0, padding: '5px 12px', borderRadius: 8,
flex: 1, 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} /> {!compact && listImportLabel}
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
</button>
</div>
<div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
@@ -163,11 +87,7 @@ export function PlacesHeader(S: SidebarState) {
const counts = {
all: baseFiltered.length,
unplanned: 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,
planned: baseFiltered.filter(p => plannedIds.has(p.id)).length,
tracks: baseFiltered.filter(p => p.route_geometry).length,
}
const tabs = ([
@@ -177,11 +97,7 @@ 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 (
// 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' }}>
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
{tabs.map(f => {
const active = filter === f.id
return (
@@ -191,20 +107,18 @@ 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', justifyContent: 'center', gap: 5,
flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99,
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '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',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.label}</span>
{f.label}
<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>
@@ -215,42 +129,6 @@ 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' }} />
@@ -259,14 +137,10 @@ 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-card text-content"
className="bg-surface-tertiary text-content"
style={{
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
// 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))',
border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
}}
/>
@@ -1,5 +1,4 @@
import { Fragment } from 'react'
import EmptyState from '../shared/EmptyState'
import { MemoPlaceRow } from './PlacesSidebarRow'
import type { SidebarState } from './usePlacesSidebar'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
@@ -15,26 +14,14 @@ 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 ? (
/* 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}
/>
<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>
) : (
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, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces'
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
@@ -31,21 +31,6 @@ 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
@@ -66,7 +51,7 @@ export interface PlacesSidebarProps {
*/
export function usePlacesSidebar(props: PlacesSidebarProps) {
const {
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS,
pushUndo, initialScrollTop, onScrollTopChange,
} = props
const { t } = useTranslation()
@@ -244,30 +229,10 @@ 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' && !plannedFilterIds.has(p.id)) return false
if (filter === 'planned' && !plannedIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
@@ -280,7 +245,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true
})
return list
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter])
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
if (element) {
@@ -348,7 +313,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
markSelectionVisited, markVisitedBusy,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, plannedFilterIds, dayScoped, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
}
}
@@ -702,8 +702,6 @@ 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}
@@ -1,261 +0,0 @@
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()
})
})
@@ -1,84 +0,0 @@
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,
)
}
@@ -1,228 +0,0 @@
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()
})
})
@@ -1,161 +0,0 @@
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,8 +11,6 @@ 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,
@@ -43,7 +41,7 @@ const day = (dayId: number, dayNumber: number): RoadtripDay => ({
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
days,
lines: [], lineDays: [],
lines: [],
accessLines: [],
vias: [],
segments: [],
@@ -204,9 +202,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 that
// folds its list, so it is the button.
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('button')).find(Boolean)!
// "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)!
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
})
@@ -293,17 +291,11 @@ 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 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.
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => {
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
expect(screen.queryByText(/had more than fits in one answer/)).not.toBeInTheDocument()
expect(screen.getByText('Aral')).toBeInTheDocument()
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
})
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'
import React, { useMemo } from 'react'
import {
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
} from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
@@ -85,14 +85,6 @@ 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)
@@ -103,16 +95,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 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}>
{/* 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>
{poi.alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
@@ -175,30 +167,9 @@ 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 (
// 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"
>
<section className="pb-1">
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
<span
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
// theme-lint-disable — see ResultBadge.
@@ -207,21 +178,13 @@ 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 }}>
{label}
{meta ? t(meta.labelKey) : category}
</span>
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
{t('roadtrip.poi.found', { count: pois.length })}
</span>
{/* 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}>
</header>
<ul>
{pois.map(poi => (
<ResultRow
key={poi.osm_id}
@@ -304,11 +267,9 @@ 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 })])
// 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.
// 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 })])
return (
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
@@ -367,16 +328,12 @@ 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.
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. */}
same search; repeating that on the button only made it small. */}
<button
type="button"
onClick={search.search}
disabled={!canSearch}
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"
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"
>
{search.loading
? <RotateCw size={15} className="animate-spin" aria-hidden />
@@ -552,7 +509,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
scene={search.loading ? 'search' : 'idle'}
mood={filteredToNothing ? 'confused' : undefined}
size={88}
fill
compact
surface="var(--bg-secondary)"
title={
search.loading
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import RoadtripLimitsCard from './RoadtripLimitsCard'
/**
* FE-ROADTRIP-LIMITS-001..012 — the numbers that decide every warning.
* FE-ROADTRIP-LIMITS-001..008 — the three 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,26 +13,14 @@ import RoadtripLimitsCard from './RoadtripLimitsCard'
* tenth of what was typed and only say so after the next reload.
*/
function open(onSave?: (key: string, value: number | string | boolean) => void) {
function open(onSave?: (key: string, value: number) => void) {
render(<RoadtripLimitsCard onSave={onSave} />)
fireEvent.click(screen.getByRole('button'))
}
/** Every number field, in the order the dialog lists them. */
/** The three inputs, 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: {
@@ -139,125 +127,4 @@ 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,38 +1,28 @@
import React, { useState } from 'react'
import {
Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship,
Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, Link2, Palette,
} from 'lucide-react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } 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, 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 { formatDurationShort } from './roadtripModel'
import type { DistanceUnit } from '../../types'
import { FS } from './typeScale'
/**
* Everything that decides when the rail speaks up about the driving.
* The three numbers that decide 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 rows of a narrow sidebar for something most
* read never: a permanent card costs three 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 answers, so what is set stays visible without the form being.
* the current answer, so what is set stays visible without the form being.
*
* 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.
* 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.
*
* 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.
* 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.
*/
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
@@ -41,38 +31,12 @@ function parseLimit(raw: string): number {
return Number.isFinite(n) && n > 0 ? n : 0
}
/**
* 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 }: {
function LimitRow({ icon: Icon, label, suffix, value, placeholder, 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
@@ -90,279 +54,59 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
}
return (
<label className="flex min-h-9 items-center gap-4">
<label className="flex items-center gap-3">
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
{/* 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>
<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>
)
}
/**
* 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.
*
* 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
/** Persists one setting. Absent leaves the dialog read-only. */
onSave?: (key: string, value: number) => 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. 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
// third too early for ever after.
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
const setRange = (shown: number) => {
const km = imperial ? shown / 0.621371 : shown
const km = distanceUnit === '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 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 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
// 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,
// 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,
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
return (
@@ -376,7 +120,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 badges below already carry a clock, a
{/* No icon tile on the trigger: the three 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">
@@ -412,213 +156,43 @@ export default function RoadtripLimitsCard({ onSave }: {
</button>
{open ? (
<Modal isOpen onClose={() => { setOpen(false); setPreview(null) }} size="4xl" title={
<Modal isOpen onClose={() => setOpen(false)} size="sm" 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">
{/* 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>
<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>
</div>
</Modal>
) : null}
@@ -5,9 +5,6 @@ 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>)
@@ -15,11 +12,6 @@ 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,
@@ -39,12 +31,12 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Berlin' }),
]
const dayId = over.dayId ?? 1
return {
dayId,
dayId: 1,
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: [] },
@@ -52,13 +44,6 @@ 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,
})),
}
}
@@ -66,7 +51,6 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
return {
days: [day()],
lines: [],
lineDays: [],
segments: [],
accessLines: [],
vias: [],
@@ -184,14 +168,11 @@ describe('RoadtripSidebar', () => {
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
})
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.
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
expect(screen.getByText('No route yet')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 3 })).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', () => {
@@ -217,15 +198,11 @@ describe('RoadtripSidebar', () => {
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
// 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)
// 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.
expect(screen.getByText('01:30').textContent).toContain('+1')
})
@@ -244,9 +221,7 @@ describe('RoadtripSidebar', () => {
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
// 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.getAllByText('Next day')).toHaveLength(2)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
@@ -324,23 +299,6 @@ 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 }),
@@ -579,443 +537,6 @@ 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,21 +140,4 @@ 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,11 +81,6 @@ 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])
@@ -169,15 +164,7 @@ 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}
{times.leaveCarry > 0 ? (
<span className="ms-0.5">
{`+${times.leaveCarry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
</span>
</div>
) : null}
@@ -1,112 +0,0 @@
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)
})
})
@@ -1,111 +0,0 @@
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 from 'react'
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
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,9 +33,61 @@ 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)
return (
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.kind')} onClose={onClose}>
// 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' }}
>
<div className="flex gap-1">
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
const on = current === key
@@ -75,6 +127,7 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
{t('roadtrip.stop.backToDestination')}
</button>
) : null}
</AnchoredPopover>
</div>,
document.body,
)
}
@@ -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', noFerry: 'No ferry' }
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
/** 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; noFerry: string },
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
): AlternativeOverlay[] {
if (!routes?.length) return []
// A single answer is not a choice; drawing it would just double the route already there.
@@ -114,9 +114,7 @@ export function buildAlternativeOverlays(
? labels.noMotorway
: route.avoids === 'toll'
? labels.noToll
: route.avoids === 'ferry'
? labels.noFerry
: index === quickest ? labels.fastest : '',
: index === quickest ? labels.fastest : '',
duration: route.duration,
distance: route.distance,
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
@@ -1,12 +1,9 @@
import { describe, it, expect } from 'vitest'
import {
boxAround,
corridorTiles,
distanceToSegmentKm,
haversineKm,
pointAtMeters,
projectOntoRoute,
sliceAtMeters,
simplifyLine,
type LatLng,
} from './corridor'
@@ -158,106 +155,3 @@ 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)
})
})
-108
View File
@@ -232,111 +232,3 @@ 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,
}
}
@@ -1,34 +0,0 @@
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))
})
})
@@ -1,43 +0,0 @@
/**
* 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 }
}
@@ -1,258 +0,0 @@
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 })
})
})
@@ -1,212 +0,0 @@
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
}
@@ -1,124 +0,0 @@
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'])
})
})
@@ -1,123 +0,0 @@
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,7 +11,6 @@ import {
parseClock,
splitIntoRuns,
sumLegSeconds,
refuelStopTypeFor,
} from './roadtripModel'
describe('formatDurationShort', () => {
@@ -90,10 +89,7 @@ describe('computeSchedule', () => {
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
})
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.
it('leaves stops before the first pinned time blank rather than inventing one', () => {
const { entries } = computeSchedule(
[
{ anchor: null, dwellMinutes: 60 },
@@ -101,39 +97,8 @@ describe('computeSchedule', () => {
],
[hours(1)],
)
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' })
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
})
it('restarts the cascade at a pinned stop instead of pushing it', () => {
@@ -580,234 +545,3 @@ 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([])
})
})
+23 -185
View File
@@ -4,8 +4,6 @@
* 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.
*
@@ -123,32 +121,9 @@ export interface ScheduleWarning {
*/
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
/**
* 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 ?? '')
/** 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 ?? '')
}
/**
@@ -156,15 +131,6 @@ export function refuelsRange(stopType: string | null | undefined, vehicle?: Vehi
* 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. */
@@ -246,10 +212,8 @@ 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
@@ -260,57 +224,28 @@ 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) 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) {
if (arrival === null) {
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
continue
}
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
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
entries.push({
arrival: formatClock(arrival),
departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)),
anchored: anchored[i],
departure: formatClock(departure),
anchored: anchor !== null,
dayOffset: offset,
})
const leg = legSeconds[i]
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
}
return { entries, warnings }
@@ -373,29 +308,6 @@ 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.
@@ -432,45 +344,13 @@ export function deriveDriveWarnings(
limits: DriveLimits,
/** Kilometres already on the tank when the day starts; null when that is unknown. */
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[] } {
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
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 = usedAfterFilling(i)
if (refuelsAt[i]) budget = 0
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
@@ -490,25 +370,7 @@ 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
@@ -516,19 +378,18 @@ 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 = usedAfterFilling(legs.length)
if (refuelsAt[legs.length]) budget = 0
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, emptyAt }
return { warnings, day, carryKm: budget }
}
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
@@ -778,26 +639,3 @@ 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, 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 },
{ 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 },
],
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: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
days, lines: [], 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