mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c735c80a4 | |||
| a8e4c45576 | |||
| 87c73e50fb | |||
| 1128bcd1c5 | |||
| 7946c2f749 | |||
| 5ef60f5641 | |||
| 9ce8b7ea9e | |||
| a9907e2f10 | |||
| a00cc889ed | |||
| 3f0eb210d8 | |||
| bd6e6312e1 | |||
| 96ab748f34 | |||
| aaf6559d5d | |||
| 5b147af0b6 | |||
| 95d1752a3d | |||
| c9e0fe52d5 | |||
| bf1900c3a0 | |||
| 59b7c35e87 | |||
| 77162aa8d5 | |||
| 29fc586930 | |||
| 7ba7338f5f | |||
| 2f71549413 | |||
| fab3d2bdf8 | |||
| 00cac02665 | |||
| 756e0fb908 | |||
| d30b916f9c | |||
| 88129e4546 | |||
| a46d3fe88a | |||
| 149b87fd47 | |||
| 15beb0ea40 | |||
| f283254bb0 | |||
| 8acfaea3c4 | |||
| a463246c1c | |||
| 2a38e79431 | |||
| 2d2f1e88bd | |||
| 1e2a5e3862 | |||
| 7e3b9fd039 | |||
| 07bb899c50 | |||
| 9543fdc3f2 | |||
| 59a1756529 | |||
| 4611f0ab7a | |||
| 6a3d4b1281 | |||
| 06ecc5135c | |||
| b2a196d1e7 | |||
| f5d6af9c77 | |||
| 9fa0ceaf49 | |||
| 900e4806ad | |||
| 4a88dbcbcf | |||
| 1761f2bcd5 | |||
| 8be594d34c | |||
| 5ac6ce40be | |||
| 2d7889c237 | |||
| cfae0a1f78 | |||
| 31061751b0 | |||
| 559b7624d0 | |||
| 2de9de8c3a | |||
| d609aefe8f | |||
| 1792fc17d0 | |||
| 72935d7e40 | |||
| 569f0ca1a5 | |||
| 5d0d106c70 | |||
| c04e740ff1 | |||
| f1120be3c1 | |||
| d3aad4c41e | |||
| 91801b10c0 | |||
| b2d9b25bb6 | |||
| 44d52ab215 | |||
| 239856f35c | |||
| 65be8db122 | |||
| 41e256bf0f | |||
| 1e1dd9b814 | |||
| c1f7587704 | |||
| 676c82a308 | |||
| 6fca238440 | |||
| 761ec4e66f | |||
| 620f4c6ecd | |||
| 36ac5de84c | |||
| 061259b3d9 | |||
| fc74991a15 | |||
| 49f97af3fb | |||
| 4215db2908 | |||
| 95c173a8a8 | |||
| 282e3c6b13 | |||
| ea9de65550 | |||
| e79a2591a1 | |||
| dfb038d90f | |||
| 56266138c6 | |||
| 11f4c740e1 | |||
| d6eac772aa | |||
| 67163e7224 | |||
| ba7bd1dd91 | |||
| c82a8f899f | |||
| 23bda0e1e1 | |||
| d1a13058a6 | |||
| 25249749ad | |||
| 9a0a6f8c3b | |||
| 7829043a00 | |||
| 7b5c8c1fae | |||
| eebe9a52e1 | |||
| f1fa155899 | |||
| 4f57c1ee13 | |||
| 3d252ac994 | |||
| e11ae6f056 | |||
| 1db55f734c | |||
| b339ef3f04 | |||
| f1eadd670d | |||
| d6955f20a6 | |||
| 6aa7884874 | |||
| 37179cb608 | |||
| 74258bd5d9 | |||
| 552bd61d11 | |||
| 647e972c8b | |||
| 07a4447bbd | |||
| 3902e77991 | |||
| 7cdd61e7fb | |||
| ed5769fc89 | |||
| b0aa7f8d95 | |||
| 48f0f29273 | |||
| 5aa36aaf3d | |||
| 273bac8917 | |||
| 2b7af6267b | |||
| d613c91e16 | |||
| 0991fa538b | |||
| 65eebec53f | |||
| 15c1a5ae06 | |||
| 4c52d16b7e | |||
| c0eb1c6e38 | |||
| fb363f22c8 | |||
| 3caafe0548 | |||
| bac85cb98a | |||
| 87a765b763 | |||
| 715993feaf | |||
| b3d69aa760 | |||
| 0a2fa32fc1 | |||
| 7f2a63e7f7 | |||
| 2a0ceb2687 | |||
| 8e2840b29d | |||
| 0454a383b3 | |||
| 18684f20f3 | |||
| 8913782694 | |||
| 52315234fd | |||
| 2572132117 | |||
| 755e9f1ca4 | |||
| b132adf3e3 | |||
| 94914295d1 | |||
| 85f5247a00 | |||
| 8a48131618 | |||
| 0a35ce83a5 | |||
| eaace6ada4 | |||
| e6b4df5213 | |||
| 2d315df8bf | |||
| 04b31ec2e5 | |||
| 8eda1475c3 | |||
| ea0589b21e | |||
| 523443f1d7 | |||
| 8c538db74d | |||
| 11a0780a5b | |||
| f75e9e9af8 | |||
| 3e532aab99 | |||
| 6953ede0e3 | |||
| f81aaa9f4b | |||
| 981cf0a143 | |||
| dc7d1f5e10 | |||
| 6f057ab9f4 | |||
| ce58de29a5 | |||
| c5208967ac | |||
| 53c246e8e5 | |||
| dba4434ef6 | |||
| 55f14c6a70 | |||
| 6d54ac629e | |||
| bafcd456c5 | |||
| 9ed88adf34 | |||
| bc10fba389 |
+2
-1
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
@@ -46,6 +46,7 @@ See `values.yaml` for more options.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.2.1
|
||||
version: 4.2.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.2.1"
|
||||
appVersion: "4.2.0"
|
||||
|
||||
@@ -80,18 +80,14 @@ spec:
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).readiness }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
|
||||
@@ -134,6 +134,25 @@ persistence:
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
|
||||
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.2.1",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+3
-2
@@ -283,7 +283,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -300,7 +300,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -314,6 +314,7 @@ export default function App() {
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
|
||||
@@ -387,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
|
||||
)
|
||||
|
||||
// Appended, not interleaved: the core list keeps the order it earned.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
|
||||
source: 'openstreetmap+plugin:demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
// The optional index being unwell must never cost the search that worked.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }],
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
|
||||
|
||||
@@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => {
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
/**
|
||||
* The one call in this file that is deliberately TWO requests, which is why it is not
|
||||
* in the list above: a search asks TREK's own indexes and any installed search-provider
|
||||
* plugin at the same time, and the caller gets one list back (#2221).
|
||||
*/
|
||||
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
|
||||
log = []
|
||||
await mapsApi.search('Rome')
|
||||
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
|
||||
'GET /api/plugin-search',
|
||||
'POST /api/maps/search',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
|
||||
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
|
||||
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
|
||||
|
||||
+266
-7
@@ -1,6 +1,8 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
@@ -59,6 +61,12 @@ import {
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
RoadtripDayTrack,
|
||||
RoadtripVia,
|
||||
RoadtripViaBatchRequest,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -464,8 +472,15 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
||||
},
|
||||
// A longer timeout than the shared 8 s, like the other routes here that wait
|
||||
// on somebody else's service. A directions link whose stops are only named
|
||||
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
|
||||
// eight stops needs about ten seconds. Giving up at eight left the server
|
||||
// finishing the import and writing the places while the browser reported a
|
||||
// failure, and a retry then spent the whole geocoding budget again only to
|
||||
// have the dedupe skip every stop.
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
@@ -646,12 +661,16 @@ 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),
|
||||
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),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
|
||||
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -1033,11 +1052,193 @@ export const memoriesApi = {
|
||||
}).then(r => r.data),
|
||||
}
|
||||
|
||||
/**
|
||||
* Place search that still answers with no network.
|
||||
*
|
||||
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
|
||||
* is the one place it is read from, so all ten call sites got the offline path
|
||||
* without any of them learning about it. `source` says 'offline-cache' rather
|
||||
* than pretending to be the index — several screens show it, and a stale answer
|
||||
* that claims to be live is worse than one that says what it is.
|
||||
*
|
||||
* Only network-level failures fall through. A 4xx or 5xx means the server did
|
||||
* answer and the caller has to see it.
|
||||
*/
|
||||
/**
|
||||
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
|
||||
*
|
||||
* Separate because the shape is different: this one is keyed by id rather than
|
||||
* by a query, and a miss has to stay a miss — a place the cache does not hold
|
||||
* must fail the way it did before, not answer with somebody else's record.
|
||||
*/
|
||||
/**
|
||||
* The rejection both search surfaces already know to swallow.
|
||||
*
|
||||
* They filter on the axios cancel code and on the DOMException name, so an
|
||||
* abort from the cache path has to look like one or it surfaces as a toast.
|
||||
*/
|
||||
function abortedError(): Error & { code: string } {
|
||||
const err = new Error('canceled') as Error & { code: string }
|
||||
err.name = 'CanceledError'
|
||||
err.code = 'ERR_CANCELED'
|
||||
return err
|
||||
}
|
||||
|
||||
async function withCachedPlace(
|
||||
placeId: string,
|
||||
online: () => Promise<{ place: Record<string, unknown> | null }>,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
|
||||
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const hit = await getCachedPlace(placeId)
|
||||
return hit ? { place: cachedToPlaceRecord(hit) } : null
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) {
|
||||
const cached = await fromCache()
|
||||
if (cached) return cached
|
||||
return online()
|
||||
}
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached) throw err
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
async function withCachedPlaces<T>(
|
||||
query: string,
|
||||
shape: (places: Record<string, unknown>[]) => T,
|
||||
online: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const fromCache = async (): Promise<Record<string, unknown>[]> => {
|
||||
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
|
||||
// The keystroke this answers may already be two keystrokes old. Both search
|
||||
// surfaces order their suggestions by aborting the previous request and
|
||||
// discarding the rejection — there is no request counter — so an offline
|
||||
// answer that resolves regardless of the signal can overwrite a newer list.
|
||||
// The two cache reads are also very unequal: a prefix hit comes off the
|
||||
// index, the substring pass walks every cached place of every trip.
|
||||
if (signal?.aborted) throw abortedError()
|
||||
return found
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) return shape(await fromCache())
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached.length) throw err
|
||||
return shape(cached)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
* is the difference between finding the temple the user stands next to and
|
||||
* one 400km away. The route has always accepted it; nothing passed it.
|
||||
*
|
||||
* 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}` }
|
||||
}),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
withCachedPlaces(
|
||||
input,
|
||||
(places) => ({
|
||||
suggestions: places.map((p) => ({
|
||||
placeId: String(p.osm_id),
|
||||
mainText: String(p.name),
|
||||
secondaryText: String(p.address || ''),
|
||||
})),
|
||||
source: 'offline-cache',
|
||||
}),
|
||||
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
signal,
|
||||
),
|
||||
// Answered from the cache when the network is not there, for the ids the
|
||||
// offline suggestion list hands out. Without it, picking an offline
|
||||
// suggestion failed here and the callers fell back to searching for
|
||||
// "name, address" — which the cache matches on the folded NAME alone, so it
|
||||
// found nothing and the user got an error toast for a place that was sitting
|
||||
// in the cache all along.
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) =>
|
||||
withCachedPlace(placeId, () =>
|
||||
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
|
||||
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
@@ -1057,10 +1258,60 @@ export const mapsApi = {
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
/**
|
||||
* Every place in a box, for the offline cache. One call per trip area, not
|
||||
* per keystroke, so the timeout is generous where the search ones are short.
|
||||
*/
|
||||
area: (
|
||||
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
|
||||
limit?: number,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
apiClient
|
||||
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
|
||||
.then(
|
||||
(r) =>
|
||||
r.data as {
|
||||
results: Record<string, unknown>[]
|
||||
truncated: boolean
|
||||
unavailable?: boolean
|
||||
},
|
||||
),
|
||||
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Road-trip via points (#1797): the places a day's drive is routed through without
|
||||
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
|
||||
* you go.
|
||||
*/
|
||||
export const roadtripApi = {
|
||||
/** Every via of the trip, so all days can be routed without a request per day. */
|
||||
listVias: (tripId: number | string) =>
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
|
||||
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
/**
|
||||
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
|
||||
* line produces dozens of them, and one request each would re-route the whole trip once
|
||||
* per point at better than a second apart.
|
||||
*/
|
||||
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
/**
|
||||
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
|
||||
* per via: the anchors are only correct as a set.
|
||||
*/
|
||||
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
|
||||
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
@@ -1190,13 +1441,19 @@ export const collabApi = {
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
|
||||
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
|
||||
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
|
||||
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
|
||||
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
|
||||
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
@@ -1230,11 +1487,13 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
// Public transit routing (#1065) — proxied through the server, which picks the
|
||||
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
|
||||
// backend as the languageCode, so station names come back in the user's script.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
|
||||
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { recordPlacePick } from './placeShadow'
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The point of this module is that it never gets in the way: it must not fire
|
||||
* when the instance has the log switched off, must not throw when the request
|
||||
* fails, and must not send coordinates finer than the ranking needs.
|
||||
*/
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: ' losteria rostock ',
|
||||
lang: 'de',
|
||||
biasLat: 54.088712,
|
||||
biasLng: 12.140493,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891234,
|
||||
pickedLng: 12.1372987,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
}
|
||||
|
||||
/** Resolves with the body the module posted, or null if it never posted. */
|
||||
function capture(status = 200) {
|
||||
let seen: unknown = null
|
||||
let resolve: (v: unknown) => void = () => {}
|
||||
const posted = new Promise<unknown>(r => { resolve = r })
|
||||
server.use(
|
||||
http.post('/api/place-shadow/pick', async ({ request }) => {
|
||||
seen = await request.json()
|
||||
resolve(seen)
|
||||
return status === 200
|
||||
? HttpResponse.json({ recorded: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status })
|
||||
}),
|
||||
)
|
||||
return { posted, seen: () => seen }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ placeShadowEnabled: true })
|
||||
})
|
||||
|
||||
describe('recordPlacePick', () => {
|
||||
it('sends nothing at all when the instance has the log switched off', async () => {
|
||||
useAuthStore.setState({ placeShadowEnabled: false })
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('trims the query and rounds every coordinate to three decimals', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
expect(await cap.posted).toMatchObject({
|
||||
query: 'losteria rostock',
|
||||
pickedLat: 54.089,
|
||||
pickedLng: 12.137,
|
||||
biasLat: 54.089,
|
||||
biasLng: 12.14,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent bias absent rather than sending a zero', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
||||
const body = (await cap.posted) as Record<string, unknown>
|
||||
expect(body.biasLat).toBeUndefined()
|
||||
expect(body.biasLng).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, query: ' ' })
|
||||
recordPlacePick({ ...PICK, pickedName: '' })
|
||||
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
||||
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not record a pick made out of the offline cache', async () => {
|
||||
// Such a row describes the local cache's ordering, not a provider's, and
|
||||
// the two figures this corpus exists for — how often the live answer was
|
||||
// first, and how often it was in the top five — are averaged over every
|
||||
// row. Mixing these in moves exactly the number a candidate index is later
|
||||
// judged against. Not merely an offline concern either: offline is a
|
||||
// setting, so the network is usually reachable and the POST would land.
|
||||
let posted = false
|
||||
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
|
||||
|
||||
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
|
||||
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
||||
const cap = capture(500)
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
expect(() => recordPlacePick(PICK)).not.toThrow()
|
||||
await cap.posted
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Records which search result the user picked, for the local shadow corpus.
|
||||
*
|
||||
* Fire and forget in the strongest sense: it never throws, never blocks the
|
||||
* pick it is describing, and does nothing at all unless the instance switched
|
||||
* the log on. Adding a place must not get slower or more fragile because
|
||||
* somebody is collecting a measurement.
|
||||
*
|
||||
* The store flag is checked first so a switched-off instance makes no request
|
||||
* at all; the server checks it again, because a client is not a permission.
|
||||
*/
|
||||
|
||||
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
|
||||
function coarse(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
|
||||
if (!useAuthStore.getState().placeShadowEnabled) return
|
||||
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
|
||||
if (pick.liveRank >= pick.liveCount) return
|
||||
// A pick out of the offline cache describes the cache's ordering, not a
|
||||
// provider's, and the two numbers this corpus exists for — how often the live
|
||||
// answer was first, and how often it was in the top five — are averaged over
|
||||
// every row. Mixing local rows in would move exactly the figure a candidate
|
||||
// index is later judged against.
|
||||
//
|
||||
// Not merely an offline concern: the offline switch is a setting, so the
|
||||
// network is usually reachable and the POST would go through.
|
||||
if (pick.source.endsWith('offline-cache')) return
|
||||
|
||||
const body: PlaceShadowPickRequest = {
|
||||
...pick,
|
||||
query: pick.query.trim().slice(0, 200),
|
||||
pickedName: pick.pickedName.slice(0, 300),
|
||||
pickedLat: coarse(pick.pickedLat),
|
||||
pickedLng: coarse(pick.pickedLng),
|
||||
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
|
||||
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
|
||||
}
|
||||
// The contract requires both; an unnamed result is not worth a row anyway.
|
||||
if (!body.query || !body.pickedName) return
|
||||
|
||||
void apiClient.post('/place-shadow/pick', body).catch(() => {
|
||||
// Deliberately silent. The user was adding a place, not running a study.
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
/**
|
||||
* Reports the routing counters this browser has tallied up.
|
||||
*
|
||||
* Fire and forget: it never throws and never blocks the route it is describing. A
|
||||
* failed report is a lost count, which is a far smaller problem than a map that waits
|
||||
* on a measurement.
|
||||
*
|
||||
* No client-side switch to check, unlike the shadow log. Counting is on unless an
|
||||
* operator turned it off, and that is a server setting the server enforces — a report
|
||||
* to a switched-off instance is answered with `recorded: false` and costs one request.
|
||||
*/
|
||||
export const routeUsageApi = {
|
||||
report: (entries: RouteUsageEntry[]): Promise<void> =>
|
||||
entries.length
|
||||
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
|
||||
/** Admin only. The figures behind "could we host a router ourselves". */
|
||||
summary: (): Promise<RouteUsageSummaryResult> =>
|
||||
apiClient.get('/route-usage/summary').then(r => r.data),
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
'collab:note:deleted',
|
||||
'collab:link:created',
|
||||
'collab:link:updated',
|
||||
'collab:link:deleted',
|
||||
'collab:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
@@ -27,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
|
||||
// routed through live in that hook and nowhere else; a store slice for them would be a
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
|
||||
@@ -74,11 +74,12 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
@@ -262,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -43,6 +43,8 @@ type Defaults = {
|
||||
blur_booking_codes?: boolean
|
||||
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
|
||||
@@ -119,6 +121,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [valhallaBase, setValhallaBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,6 +132,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
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))
|
||||
@@ -150,6 +156,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
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))
|
||||
@@ -219,11 +227,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
<div>
|
||||
<p className="text-sm text-content-faint mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
stayed empty. Grouped by subject rather than by height — what a user ends up
|
||||
seeing on the left, everything that configures the map on the right.
|
||||
The columns are deliberately uneven: the left one never needs more than the
|
||||
widest option row, while the right one holds four text fields whose values
|
||||
are 60-character tile URLs. Only the column gap is set, because Section
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
@@ -321,7 +341,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
@@ -365,6 +387,54 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Instance configuration, not a taste, which is why it sits here rather
|
||||
than in a user's own Map settings. The origin has to appear in the CSP
|
||||
`connect-src` the server emits at boot, and that list is built from
|
||||
this default — a value on a personal settings row is read by the route
|
||||
calculator and then refused by the browser, so the whole app stops
|
||||
routing with no error it could report. The write route refuses one
|
||||
from a non-admin for the same reason. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.routingBase')}
|
||||
<ResetButton field="routing_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
|
||||
onBlur={() => save({ routing_base_url: routingBase.trim() })}
|
||||
placeholder="https://osrm.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.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, {
|
||||
@@ -490,6 +560,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,10 +8,11 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -19,7 +20,8 @@ import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
@@ -84,6 +86,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
// One note open at a time: two expanded rows next to each other read as a mess,
|
||||
// and the point of the collapse is that the list stays scannable.
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
|
||||
@@ -492,6 +495,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
{previewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={previewReceipts.receipts}
|
||||
initialIndex={previewReceipts.initialIndex}
|
||||
onClose={() => setPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||
@@ -762,6 +773,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{t('costs.unfinished')}
|
||||
</span>
|
||||
)}
|
||||
{(e.receipts || []).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
|
||||
}}
|
||||
title={t('costs.viewReceipt')}
|
||||
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Receipt size={12} className="text-content-muted" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
@@ -1122,6 +1159,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
return m
|
||||
})
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts(prev => prev.filter(r => r.id !== receiptId))
|
||||
}
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
@@ -1318,12 +1373,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
))
|
||||
// Only cleared once the save went through, so a retry after a failure
|
||||
// does not upload a second copy of every file.
|
||||
setPendingReceiptFiles([])
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('common.unknownError'))
|
||||
} catch (err) {
|
||||
// A receipt the rollback could not remove is still on the trip, and the
|
||||
// user is the only one who can clear it out of the Files tab.
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||
} finally {
|
||||
setUploadingReceipt(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -1633,8 +1699,91 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
|
||||
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
<div className={panelCls}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
handleReceiptFileSelect(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
|
||||
<Plus size={13} /> {t('costs.attachReceipt')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploadingReceipt && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
|
||||
{t('common.saving')}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
|
||||
{t('costs.noReceipts')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{receipts.map((r, rIdx) => (
|
||||
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
|
||||
className="text-content hover:underline"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<Receipt size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveReceipt(r.id)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingReceiptFiles.map((file, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePendingReceipt(idx)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalPreviewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={modalPreviewReceipts.receipts}
|
||||
initialIndex={modalPreviewReceipts.initialIndex}
|
||||
onClose={() => setModalPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
|
||||
// on top of the expense form.
|
||||
import { vi } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
|
||||
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
|
||||
|
||||
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
|
||||
return {
|
||||
id: 1,
|
||||
filename: 'stored.jpg',
|
||||
original_name: 'lunch.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 100,
|
||||
url: '/uploads/files/stored.jpg',
|
||||
...over,
|
||||
} as BudgetItemReceipt
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
|
||||
})
|
||||
|
||||
describe('ReceiptPreviewModal', () => {
|
||||
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
|
||||
// The viewer renders through a portal, so it lives on document.body.
|
||||
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
|
||||
authUrl.get.mockRejectedValue(new Error('403'))
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
|
||||
const onClose = vi.fn()
|
||||
const parent = vi.fn()
|
||||
document.addEventListener('keydown', parent)
|
||||
try {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// The expense form registers its own Escape handler on `document`; one
|
||||
// keypress must not close both and throw away the user's edits.
|
||||
expect(parent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
document.removeEventListener('keydown', parent)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
|
||||
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
interface ReceiptPreviewModalProps {
|
||||
receipts: BudgetItemReceipt[]
|
||||
initialIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
|
||||
const [signedUrl, setSignedUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const current = receipts[index]
|
||||
const isImg = isImage(current?.mime_type)
|
||||
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSignedUrl('')
|
||||
|
||||
getAuthUrl(current.url, 'download')
|
||||
.then(url => {
|
||||
if (!cancelled) {
|
||||
setSignedUrl(url)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [current?.url])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// The preview opens on top of the expense form, and both the desktop
|
||||
// Modal and the phone MSheet close themselves on Escape from a listener
|
||||
// on `document`. Without this the one keypress closed the form too and
|
||||
// threw away whatever the user had typed into it.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
|
||||
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
|
||||
}
|
||||
// Capture phase, so this runs before the listeners the parents put on
|
||||
// `document`; stopPropagation on the bubble phase would be too late.
|
||||
window.addEventListener('keydown', handleKey, true)
|
||||
return () => window.removeEventListener('keydown', handleKey, true)
|
||||
}, [index, receipts.length, onClose])
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const handleOpenTab = () => {
|
||||
openFileUrl(current.url, current.original_name).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
triggerDownload(current.url, current.original_name)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 950,
|
||||
height: '94vh',
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header - identical to PdfPreviewModal */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{current.original_name}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-faint)',
|
||||
display: 'flex',
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab strip if multiple receipts */}
|
||||
{receipts.length > 1 && (
|
||||
<div
|
||||
className="bg-surface-secondary"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.map((r, i) => {
|
||||
const active = i === index
|
||||
const isRImg = isImage(r.mime_type)
|
||||
const Icon = isRImg ? FileImage : FileText
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '5px 11px',
|
||||
borderRadius: 8,
|
||||
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||
background: active ? 'var(--bg-card)' : 'none',
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
|
||||
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{r.original_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
opacity: active ? 0.8 : 0.5,
|
||||
marginLeft: 2,
|
||||
}}
|
||||
>
|
||||
#{i + 1}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: isImg ? '#0f141c' : 'var(--bg-card)',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.length > 1 && index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i - 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{receipts.length > 1 && index < receipts.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i + 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPdf ? (
|
||||
<object
|
||||
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
|
||||
title={current.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
textDecoration: 'underline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.downloadPdf')}
|
||||
</button>
|
||||
</p>
|
||||
</object>
|
||||
) : isImg ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{signedUrl ? (
|
||||
<img
|
||||
src={signedUrl}
|
||||
alt={current.original_name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 6,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-content-faint" style={{ padding: 48 }}>
|
||||
{loading ? `${t('common.loading')}...` : current.original_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
|
||||
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
|
||||
{current.original_name}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.download')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
|
||||
// expense, and taking the uploads back out when the expense fails to save.
|
||||
import { vi } from 'vitest'
|
||||
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
|
||||
|
||||
const files = vi.hoisted(() => ({
|
||||
upload: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
permanentDelete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({ filesApi: files }))
|
||||
|
||||
function file(name: string) {
|
||||
return new File(['x'], name, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
|
||||
files.delete.mockReset().mockResolvedValue({})
|
||||
files.permanentDelete.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('uploadReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], 42)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBe('42')
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], null)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
|
||||
files.upload
|
||||
.mockResolvedValueOnce({ file: { id: 11 } })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ file: { id: 13 } })
|
||||
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
|
||||
})
|
||||
})
|
||||
|
||||
describe('discardReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
|
||||
const order: string[] = []
|
||||
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
|
||||
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
|
||||
|
||||
expect(await discardReceipts(3, [9])).toEqual([])
|
||||
expect(order).toEqual(['delete', 'permanent'])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
expect(await discardReceipts(3, [9, 10])).toEqual([9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWithReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockResolvedValue('saved')
|
||||
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
|
||||
expect(save).toHaveBeenCalledWith([5, 6])
|
||||
expect(out.result).toBe('saved')
|
||||
expect(files.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockRejectedValue(new Error('nope'))
|
||||
|
||||
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
const save = vi.fn()
|
||||
|
||||
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
|
||||
expect(err.stuckReceiptIds).toEqual([5])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { filesApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Uploading receipts alongside an expense, and undoing it when the expense
|
||||
* itself fails to save.
|
||||
*
|
||||
* One module for both shells. The desktop form and the phone sheet each had
|
||||
* their own copy of this, three near-identical try/catch blocks apiece, which is
|
||||
* six places for the same bug to sit and six copies against the duplication
|
||||
* budget the PR gate measures.
|
||||
*/
|
||||
|
||||
/** Upload each file and return the ids the server handed back, in order. */
|
||||
export async function uploadReceipts(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
// Only an existing expense can be named here. A new one has no id yet, so
|
||||
// its receipts are linked by the create call instead.
|
||||
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
|
||||
const res = await filesApi.upload(tripId, fd)
|
||||
if (res?.file?.id) ids.push(res.file.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo an upload after the save it belonged to failed.
|
||||
*
|
||||
* Two calls per file, and the order matters: the permanent route only accepts a
|
||||
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
|
||||
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
|
||||
* delete puts it there first. It also drops the receipt link the upload created,
|
||||
* so a failed edit does not leave the file attached to the expense.
|
||||
*
|
||||
* Returns the ids it could not remove. The caller is expected to say so rather
|
||||
* than swallow it: a file left behind is a file the user has to find in the
|
||||
* Files tab and delete by hand.
|
||||
*/
|
||||
export async function discardReceipts(
|
||||
tripId: number | string,
|
||||
ids: readonly number[],
|
||||
): Promise<number[]> {
|
||||
const stuck: number[] = []
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
await filesApi.permanentDelete(tripId, id)
|
||||
} catch {
|
||||
stuck.push(id)
|
||||
}
|
||||
}
|
||||
return stuck
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the pending receipts, run the save, and take the uploads back out
|
||||
* again if it throws.
|
||||
*
|
||||
* The rollback runs for a failure in the save AND for one partway through the
|
||||
* upload loop, which is why the ids are collected outside the try.
|
||||
*/
|
||||
export async function saveWithReceipts<T>(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
save: (uploadedIds: number[]) => Promise<T>,
|
||||
): Promise<{ result: T; stuckIds: number[] }> {
|
||||
const uploaded: number[] = []
|
||||
try {
|
||||
for (const file of files) {
|
||||
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
|
||||
if (id != null) uploaded.push(id)
|
||||
}
|
||||
const result = await save(uploaded)
|
||||
return { result, stuckIds: [] }
|
||||
} catch (err) {
|
||||
const stuck = await discardReceipts(tripId, uploaded)
|
||||
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
|
||||
import type { User } from '../../types'
|
||||
import { useCollabChat } from './useCollabChat'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
@@ -14,7 +14,7 @@ interface CollabChatProps {
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const S = useCollabChat(tripId, currentUser)
|
||||
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
<ChatMessages {...S} />
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
@@ -48,6 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
{canEdit && (
|
||||
@@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -76,15 +80,16 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
{canEdit && (
|
||||
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <>
|
||||
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
|
||||
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
|
||||
</>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { CollabChatAttachment } from './CollabChatAttachment'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -166,6 +167,7 @@ export function ChatMessages(props: any) {
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
interface CollabLink {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
function LinkIcon({ url, title }: { url: string; title: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
|
||||
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
|
||||
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
|
||||
}
|
||||
|
||||
export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const canEdit = useCanDo()('collab_edit', trip)
|
||||
const toast = useToast()
|
||||
const [links, setLinks] = useState<CollabLink[]>([])
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
collabApi.getLinks(tripId)
|
||||
.then(d => { if (!cancelled) setLinks(d.links || []) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
console.error('Failed to load collab links:', err)
|
||||
toast.error(t('common.error'))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, toast, t])
|
||||
|
||||
// Live sync, the same shape the notes panel uses. Without it the three
|
||||
// collab:link events the server broadcasts would arrive nowhere and a link
|
||||
// somebody else added would only appear on the next reload.
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
|
||||
// The panel is not remounted on a trip change, so an event still in
|
||||
// flight from the trip we just left must not land in this list.
|
||||
if (String(msg?.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:link:created' && msg.link) {
|
||||
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:link:updated' && msg.link) {
|
||||
setLinks(prev => prev
|
||||
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
}
|
||||
if (msg.type === 'collab:link:deleted' && msg.linkId) {
|
||||
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId])
|
||||
|
||||
// Every write says something when it fails. Silence left the pin looking
|
||||
// unchanged and the delete looking ignored, with an unhandled rejection in
|
||||
// the console as the only trace.
|
||||
const add = async (event?: FormEvent) => {
|
||||
event?.preventDefault()
|
||||
if (!title.trim() || !url.trim() || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
|
||||
setLinks(v => [d.link, ...v])
|
||||
setTitle(''); setUrl(''); setExpanded(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to add collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (link: CollabLink) => {
|
||||
try {
|
||||
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
|
||||
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
} catch (err) {
|
||||
console.error('Failed to pin collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await collabApi.deleteLink(tripId, id)
|
||||
setLinks(v => v.filter(x => x.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
|
||||
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
|
||||
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
|
||||
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
|
||||
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
|
||||
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
|
||||
</form>}
|
||||
</div>}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
|
||||
</div>
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
|
||||
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
|
||||
vi.mock('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</div> }))
|
||||
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
|
||||
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
@@ -39,11 +40,12 @@ describe('CollabPanel', () => {
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-001
|
||||
it('desktop layout renders all four panels', () => {
|
||||
it('desktop layout renders all five panels', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
@@ -55,6 +57,7 @@ describe('CollabPanel', () => {
|
||||
// Tab buttons exist
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^links$/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
|
||||
// Only chat visible by default
|
||||
@@ -73,6 +76,13 @@ describe('CollabPanel', () => {
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mobile: Links tab shows the translated label, not the i18n key', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-004
|
||||
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||
setViewport(375)
|
||||
@@ -147,7 +157,7 @@ describe('CollabPanel', () => {
|
||||
// FE-W5CPN-001 to FE-W5CPN-013
|
||||
// The desktop branch picks a different layout for every combination of enabled
|
||||
// collab features, so each combination gets its own case.
|
||||
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
|
||||
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
|
||||
|
||||
describe('CollabPanel feature combinations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -172,6 +182,7 @@ describe('CollabPanel feature combinations', () => {
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -253,6 +264,42 @@ describe('CollabPanel feature combinations', () => {
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The three-or-more branch used to render notes and links as a pair and drop
|
||||
// whichever of the two was on its own, so these walk every shape of it.
|
||||
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
|
||||
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
|
||||
setViewport(375)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import CollabLinks from './CollabLinks'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
@@ -28,6 +29,7 @@ interface TripMember {
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
links?: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
@@ -39,10 +41,11 @@ interface CollabPanelProps {
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
@@ -50,12 +53,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
const { t } = useTranslation()
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
// Older server/admin configs predate the Links feature; merge defaults so a
|
||||
// missing `links` key does not silently hide the new panel.
|
||||
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey),
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
@@ -70,11 +75,28 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.notes && 'notes', features.links && 'links',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
// One place decides what a panel id renders. It was spelled out at five call
|
||||
// sites, and the layout branch below dropped a panel precisely because one of
|
||||
// those copies did not list it.
|
||||
const renderPanel = (p: string) => (
|
||||
<>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'links' && <CollabLinks tripId={tripId} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</>
|
||||
)
|
||||
const panelRow = (ids: string[]) => (
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
|
||||
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
@@ -99,35 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
{rightPanels.length <= 2 && panelRow(rightPanels)}
|
||||
{rightPanels.length >= 3 && (() => {
|
||||
// Two rows, split by kind rather than by a pair being present:
|
||||
// the old condition needed notes AND links together, so turning
|
||||
// the new Links feature off dropped Notes out of the layout
|
||||
// entirely. Each row renders only when it has something in it.
|
||||
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
|
||||
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
|
||||
return <>
|
||||
{written.length > 0 && panelRow(written)}
|
||||
{rest.length > 0 && panelRow(rest)}
|
||||
</>
|
||||
)}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -135,26 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{panels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -170,7 +160,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{tabs.map(tab => {
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
@@ -187,6 +177,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The images pinned to a chat message before it is sent.
|
||||
*
|
||||
* One hook for both shells. The desktop hook and the phone screen each carried
|
||||
* their own copy of the same picking, previewing and clearing, which is two
|
||||
* places for the same leak and a duplication bill on every line.
|
||||
*
|
||||
* Object URLs are held in a ref and revoked when the file they belong to goes,
|
||||
* when the list is cleared and on unmount. Rebuilding every preview on each
|
||||
* change, which is what both copies did, leaked one URL per image per keystroke
|
||||
* of picking, and did it from inside a setState updater, so React ran it twice
|
||||
* in development and leaked twice as fast.
|
||||
*/
|
||||
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
export const MAX_CHAT_IMAGES = 4
|
||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export interface ChatImages {
|
||||
files: File[]
|
||||
previews: string[]
|
||||
/** Returns false when something was dropped, so the caller can say why. */
|
||||
add: (incoming: FileList | File[]) => boolean
|
||||
remove: (index: number) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function useChatImages(): ChatImages {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [previews, setPreviews] = useState<string[]>([])
|
||||
const urls = useRef<string[]>([])
|
||||
|
||||
const revokeAll = useCallback(() => {
|
||||
urls.current.forEach(URL.revokeObjectURL)
|
||||
urls.current = []
|
||||
}, [])
|
||||
|
||||
useEffect(() => revokeAll, [revokeAll])
|
||||
|
||||
const add = useCallback((incoming: FileList | File[]) => {
|
||||
const all = Array.from(incoming)
|
||||
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
|
||||
if (valid.length) {
|
||||
setFiles(prev => {
|
||||
const room = MAX_CHAT_IMAGES - prev.length
|
||||
const added = valid.slice(0, Math.max(0, room))
|
||||
// Only the new files get a URL; the ones already in the list keep theirs.
|
||||
const fresh = added.map(URL.createObjectURL)
|
||||
urls.current = [...urls.current, ...fresh]
|
||||
setPreviews(p => [...p, ...fresh])
|
||||
return [...prev, ...added]
|
||||
})
|
||||
}
|
||||
return valid.length === all.length
|
||||
}, [])
|
||||
|
||||
const remove = useCallback((index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index))
|
||||
setPreviews(prev => {
|
||||
const gone = prev[index]
|
||||
if (gone) {
|
||||
URL.revokeObjectURL(gone)
|
||||
urls.current = urls.current.filter(u => u !== gone)
|
||||
}
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
revokeAll()
|
||||
setFiles([])
|
||||
setPreviews([])
|
||||
}, [revokeAll])
|
||||
|
||||
return { files, previews, add, remove, clear }
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatImages } from './useChatImages'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
@@ -23,6 +24,8 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const images = useChatImages()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
@@ -38,6 +41,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
@@ -117,23 +121,35 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const addImageFiles = useCallback((incoming: File[] | FileList) => {
|
||||
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
|
||||
}, [images, toast, t])
|
||||
const removeImage = images.remove
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
|
||||
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
if ((!body && !images.files.length) || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
let data
|
||||
if (images.files.length) {
|
||||
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
|
||||
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
|
||||
} else {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
|
||||
data = await collabApi.sendMessage(tripId, payload)
|
||||
}
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
@@ -175,5 +191,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
interface LocationBias {
|
||||
low: { lat: number; lng: number }
|
||||
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
|
||||
/**
|
||||
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
|
||||
*
|
||||
* The panel is positioned against the FILTER ROW rather than against this
|
||||
* button, so it opens across the full width of the row instead of the half its
|
||||
* own trigger occupies. The two filters sit side by side and only one can be
|
||||
* open at a time, so the wider panel costs nothing and stops list names being
|
||||
* truncated at roughly ten characters. The row carries the `relative` this
|
||||
* needs; there is no other call site.
|
||||
*/
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
}, [open])
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1" ref={ref}>
|
||||
<div className="min-w-0 flex-1" ref={ref}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
||||
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
||||
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
{options.map(o => (
|
||||
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
@@ -173,7 +183,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
// Same 320px as the details column on the other side of the form: two panels
|
||||
// of different widths flanking one form read as a mistake rather than a
|
||||
// hierarchy, and neither of them is the more important one.
|
||||
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Bookmark size={15} className="text-accent" />
|
||||
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
||||
@@ -189,7 +202,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
/>
|
||||
</div>
|
||||
{lists.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex gap-2">
|
||||
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
</div>
|
||||
@@ -201,7 +214,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
|
||||
<EmptyState
|
||||
scene="search"
|
||||
title={t('collections.picker.empty')}
|
||||
size={84}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{page.map(place => (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
function open(value = '2026-09-11') {
|
||||
const onChange = vi.fn()
|
||||
render(<DatePicker value={value} onChange={onChange} />)
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
|
||||
const prev = (name: RegExp) => screen.getByRole('button', { name })
|
||||
|
||||
describe('DatePicker year navigation (#2318)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
|
||||
// Back on the months, of the chosen year.
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(prev(/previous month/i))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(prev(/previous year/i))
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(prev(/previous years/i))
|
||||
expect(header()).toHaveTextContent('2004 – 2015')
|
||||
await user.click(screen.getByRole('button', { name: /next years/i }))
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
await user.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith('2023-03-15')
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
const trigger = screen.getByText(/Sep 11, 2026/)
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
|
||||
* learnt one calendar in TREK knows the other. Without it the only way to a
|
||||
* different year was one month at a time, which on a photo picker asked to
|
||||
* cover a trip three years back meant thirty-six clicks (#2318).
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
|
||||
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
|
||||
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months')
|
||||
} else if (view === 'months') {
|
||||
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
|
||||
setView('years')
|
||||
}
|
||||
}
|
||||
const selectMonth = (month: number) => {
|
||||
setViewMonth(p => ({ ...p, month }))
|
||||
setView('days')
|
||||
}
|
||||
const selectYear = (year: number) => {
|
||||
setViewMonth(p => ({ ...p, year }))
|
||||
setView('months')
|
||||
}
|
||||
const toggleOpen = () => {
|
||||
// Reopening lands on the days again: a picker left on the year grid
|
||||
// would otherwise open there next time, one level away from a date.
|
||||
setView('days')
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
|
||||
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
|
||||
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
|
||||
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
|
||||
const headerLabel = view === 'days' ? monthName
|
||||
: view === 'months' ? String(viewMonth.year)
|
||||
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
|
||||
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
|
||||
: view === 'months' ? t('common.datepicker.selectYear') : undefined
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const monthNames = Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
|
||||
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
|
||||
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
aria-label={headerAria}
|
||||
disabled={view === 'years'}
|
||||
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
{view === 'months' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{monthNames.map((name, i) => (
|
||||
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'years' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map(y => (
|
||||
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'days' && (
|
||||
<>
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-020
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -324,6 +324,39 @@ describe('ProviderPicker', () => {
|
||||
expect(range.to.endsWith('-09')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-021: a location picked after the photos loaded reorders the grid', async () => {
|
||||
// contextLocation is live editor state: the place search and "use my location"
|
||||
// write it while this picker stays mounted, and the key is only provider plus
|
||||
// date. Sorting inside the fetch instead of in a memo leaves the grid frozen
|
||||
// while the caption above it already claims nearest-first.
|
||||
searchReturns([
|
||||
asset('rome', { lat: 41.9, lng: 12.5, takenAt: '2026-03-15T09:00:00.000Z' }),
|
||||
asset('helsinki', { lat: 60.17, lng: 24.94, takenAt: '2026-03-15T11:00:00.000Z' }),
|
||||
])
|
||||
const props = {
|
||||
provider: 'immich',
|
||||
userId: 42,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds: new Set<string>(),
|
||||
onClose: vi.fn(),
|
||||
onAdd: vi.fn(async () => {}),
|
||||
} as React.ComponentProps<typeof ProviderPicker>
|
||||
const { container, rerender } = render(<ProviderPicker {...props} />)
|
||||
await screen.findByText('March 15, 2026')
|
||||
|
||||
const order = () => Array.from(container.querySelectorAll('img'))
|
||||
.map(img => (img.getAttribute('src') || '').split('/assets/0/')[1]?.split('/')[0])
|
||||
.filter(Boolean)
|
||||
|
||||
// Nothing to be near yet, so newest first.
|
||||
expect(order()).toEqual(['helsinki', 'rome'])
|
||||
|
||||
rerender(<ProviderPicker {...props} contextLocation={{ lat: 41.9, lng: 12.5, name: 'Rome' }} />)
|
||||
|
||||
await waitFor(() => expect(order()).toEqual(['rome', 'helsinki']))
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-020: closes through the header button and the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, onClose } = mountPicker()
|
||||
|
||||
@@ -1,189 +1,244 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { memoriesApi } from '../../api/client'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import { Calendar, Camera, Check, ChevronRight, Play, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
groupPhotosByDate,
|
||||
sortProviderPhotos,
|
||||
type GeoPoint,
|
||||
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker';
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger';
|
||||
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] };
|
||||
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
|
||||
provider: string
|
||||
userId: number
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
|
||||
initialDate?: string
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null
|
||||
initialEntryId?: number | null
|
||||
embedded?: boolean
|
||||
export function ProviderPicker({
|
||||
provider,
|
||||
userId,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds,
|
||||
onClose,
|
||||
onAdd,
|
||||
initialDate,
|
||||
contextLocation,
|
||||
initialEntryId,
|
||||
embedded = false,
|
||||
}: {
|
||||
provider: string;
|
||||
userId: number;
|
||||
entries: JourneyEntry[];
|
||||
trips: JourneyTrip[];
|
||||
existingAssetIds: Set<string>;
|
||||
onClose: () => void;
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>;
|
||||
initialDate?: string;
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null;
|
||||
initialEntryId?: number | null;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip');
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [albums, setAlbums] = useState<
|
||||
Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
||||
>([]);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [searchFrom, setSearchFrom] = useState('');
|
||||
const [searchTo, setSearchTo] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(
|
||||
new Map()
|
||||
);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null);
|
||||
const [addToOpen, setAddToOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// compute trip range
|
||||
const tripRange = useMemo(() => {
|
||||
let from = '', to = ''
|
||||
let from = '',
|
||||
to = '';
|
||||
for (const t of trips) {
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date;
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date;
|
||||
}
|
||||
return { from, to }
|
||||
}, [trips])
|
||||
return { from, to };
|
||||
}, [trips]);
|
||||
|
||||
const cancelPending = () => {
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
abortRef.current = new AbortController()
|
||||
return abortRef.current.signal
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
return abortRef.current.signal;
|
||||
};
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending()
|
||||
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||
setSearchFrom(from)
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
const signal = cancelPending();
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setSearchFrom(from);
|
||||
setSearchTo(to);
|
||||
setSearchPage(page);
|
||||
try {
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
|
||||
const assets = data.assets || [];
|
||||
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
|
||||
setHasMore(!!data.hasMore);
|
||||
} catch {
|
||||
// A cancelled request is about to be replaced by the next one, so leave
|
||||
// its paging state alone.
|
||||
if (!signal.aborted) setHasMore(false)
|
||||
if (!signal.aborted) setHasMore(false);
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
if (loadingMore || !hasMore) return;
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
|
||||
};
|
||||
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
const signal = cancelPending();
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
setHasMore(false);
|
||||
try {
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||
} catch { /* ignore */ }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
const assets = (await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [];
|
||||
setPhotos(assets);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!signal.aborted) setLoading(false);
|
||||
};
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || []);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// load on mount / filter change
|
||||
useEffect(() => {
|
||||
if (filter === 'day' && initialDate) {
|
||||
searchPhotos(initialDate, initialDate)
|
||||
searchPhotos(initialDate, initialDate);
|
||||
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
searchPhotos(tripRange.from, tripRange.to);
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '')
|
||||
searchPhotos('', '');
|
||||
} else if (filter === 'album' && albums.length === 0) {
|
||||
loadAlbums()
|
||||
loadAlbums();
|
||||
}
|
||||
}, [filter])
|
||||
}, [filter]);
|
||||
|
||||
const handleCustomSearch = () => {
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
||||
}
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo);
|
||||
};
|
||||
|
||||
// Sorted here rather than in the two fetches above. contextLocation is live
|
||||
// editor state: the place search and "use my location" write it while this
|
||||
// picker stays mounted, and its key is only provider plus date, so a fetch-time
|
||||
// sort would leave the grid frozen while the caption already says "near Rome".
|
||||
// It also keeps the ranking global instead of per page, which a sort inside
|
||||
// the appending search cannot do.
|
||||
const sortedPhotos = useMemo(
|
||||
() => sortProviderPhotos(photos, contextLocation),
|
||||
[photos, contextLocation?.lat, contextLocation?.lng],
|
||||
)
|
||||
);
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
next.delete(id);
|
||||
} else {
|
||||
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
|
||||
const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType;
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType });
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery')
|
||||
? entries.find((e) => e.id === targetEntryId)?.title ||
|
||||
entries.find((e) => e.id === targetEntryId)?.entry_date ||
|
||||
t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
|
||||
className={embedded
|
||||
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
|
||||
}
|
||||
role="presentation"
|
||||
onClick={embedded ? undefined : onClose}
|
||||
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
|
||||
onTouchMove={(e) => {
|
||||
if (!embedded && e.target === e.currentTarget) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={embedded
|
||||
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
|
||||
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
|
||||
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
|
||||
}
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>}
|
||||
{!embedded && (
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
{[
|
||||
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button type="button"
|
||||
].map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
|
||||
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
filter === f.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
@@ -192,180 +247,252 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : f.label}
|
||||
) : (
|
||||
f.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter content — always visible row */}
|
||||
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<div className="flex min-h-[36px] items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
(
|
||||
{Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) +
|
||||
1}{' '}
|
||||
days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'custom' && (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
|
||||
<span className="text-zinc-400 text-[12px]">—</span>
|
||||
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
||||
<button type="button" onClick={handleCustomSearch}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex gap-2 overflow-x-auto flex-1">
|
||||
{albums.map((a: any) => (
|
||||
<button type="button"
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
{filter === 'custom' && (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customFrom} onChange={setCustomFrom} />
|
||||
</div>
|
||||
<span className="text-[12px] text-zinc-400">—</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customTo} onChange={setCustomTo} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSearch}
|
||||
className="flex-shrink-0 rounded-lg bg-zinc-900 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex flex-1 gap-2 overflow-x-auto">
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setSelectedAlbum(a.id);
|
||||
setSelectedAlbumPassphrase(a.passphrase);
|
||||
loadAlbumPhotos(a);
|
||||
}}
|
||||
className={`flex-shrink-0 rounded-lg border px-2.5 py-1 text-[11px] font-medium whitespace-nowrap ${
|
||||
selectedAlbum === a.id
|
||||
? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}
|
||||
{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && (
|
||||
<span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
||||
<button type="button"
|
||||
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
||||
<button type="button"
|
||||
key={e.id}
|
||||
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
{!embedded && (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-zinc-50 px-6 py-2.5 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] text-zinc-500 uppercase">
|
||||
{t('journey.picker.addTo')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-200 px-3 py-1.5 text-[12px] font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute top-full left-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTargetEntryId(null);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{entries.filter(
|
||||
(e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]'
|
||||
).length > 0 && <div className="my-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
|
||||
{entries
|
||||
.filter((e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||
.map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e.id}
|
||||
onClick={() => {
|
||||
setTargetEntryId(e.id);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`w-full truncate px-3 py-2 text-left text-[12px] ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title ||
|
||||
e.location_name ||
|
||||
new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
)}
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && sortedPhotos.length > 0 && (() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
allSelected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!loading &&
|
||||
sortedPhotos.length > 0 &&
|
||||
(() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
|
||||
if (selectable.length === 0) return null;
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-white px-4 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(
|
||||
new Map(
|
||||
selectable.map((a: any) => [
|
||||
a.id,
|
||||
{
|
||||
albumId: selectedAlbum ?? undefined,
|
||||
passphrase: selectedAlbumPassphrase,
|
||||
mediaType: a.mediaType,
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div
|
||||
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
|
||||
allSelected
|
||||
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
|
||||
</div>
|
||||
) : sortedPhotos.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
||||
{filter === 'trip' && !tripRange.from
|
||||
? t('journey.trips.noTripsLinkedSettings')
|
||||
: t('journey.detail.noPhotos')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(sortedPhotos).map(group => (
|
||||
{groupPhotosByDate(sortedPhotos).map((group) => (
|
||||
<div key={group.date}>
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
const isSelected = selected.has(asset.id);
|
||||
const alreadyAdded = existingAssetIds.has(asset.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -374,42 +501,47 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
aria-pressed={isSelected}
|
||||
aria-label={asset.city || group.label}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
|
||||
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
|
||||
if (!img.src.includes('/original')) img.src = original;
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.mediaType === 'video' && (
|
||||
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
|
||||
<Play size={8} className="ml-px text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
|
||||
<p className="truncate text-[8px] text-white">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,35 +553,58 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-200/60 px-2.5 py-1 text-[11px] leading-none text-zinc-500 dark:bg-zinc-700/60 dark:text-zinc-400">
|
||||
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] leading-none font-bold text-white dark:bg-white dark:text-zinc-900">
|
||||
{selected.size}
|
||||
</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-200 px-3.5 py-2 text-[13px] font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
|
||||
g.assetIds.push(assetId)
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
|
||||
groupMap.set(passphrase, g)
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>();
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] };
|
||||
g.assetIds.push(assetId);
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image');
|
||||
groupMap.set(passphrase, g);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({
|
||||
assetIds: g.assetIds,
|
||||
mediaTypes: g.mediaTypes,
|
||||
passphrase,
|
||||
}));
|
||||
await onAdd(groups, targetEntryId);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={selected.size === 0 || adding}
|
||||
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
{adding ? (
|
||||
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-zinc-400 border-t-white dark:border-t-zinc-900" />
|
||||
) : (
|
||||
<>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-006
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-009
|
||||
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
@@ -87,4 +87,44 @@ describe('ScrollTrigger', () => {
|
||||
unmount()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-007: a fresh callback identity alone does not rebuild the observer', () => {
|
||||
// Both callers hand in a new arrow every render; rebuilding for that churn
|
||||
// replays the initial callback and pulls pages nobody asked for.
|
||||
const { rerender } = render(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-008: the surviving observer calls the newest callback, not the one it was built with', () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={first} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={second} loading={false} />)
|
||||
|
||||
intersect(observers[0], true)
|
||||
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-009: a settled page really re-observes the sentinel', () => {
|
||||
// A browser observer only fires on a threshold crossing plus once per
|
||||
// observe(). A sentinel that never leaves the 200px margin therefore only
|
||||
// gets another callback because the loading flip rebuilds the observer, so
|
||||
// loading must stay a dependency rather than moving into a ref as well.
|
||||
const onVisible = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={onVisible} loading />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={onVisible} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(2)
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,24 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
export function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
// Both callers hand in a fresh arrow on every render — the picker its
|
||||
// loadMorePhotos, the gallery its setVisibleCount step. With onVisible in the
|
||||
// effect deps, every unrelated re-render tore the observer down and observed
|
||||
// again, and each observe() replays the initial callback. The ref keeps the
|
||||
// observer out of that churn.
|
||||
//
|
||||
// loading stays a dependency on purpose: re-observing after a page has landed
|
||||
// is the only thing that still produces a callback when the sentinel never
|
||||
// left the 200px margin.
|
||||
const onVisibleRef = useRef(onVisible)
|
||||
onVisibleRef.current = onVisible
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisibleRef.current() }, { rootMargin: '200px' })
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [onVisible, loading])
|
||||
}, [loading])
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-4 mt-2">
|
||||
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
||||
|
||||
@@ -86,6 +86,9 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
|
||||
// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These
|
||||
// cases are about the GL layer, so the probe says yes here.
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} }));
|
||||
|
||||
const entriesWithCoords = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap'
|
||||
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
|
||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
@@ -162,7 +162,8 @@ function JourneyMap(
|
||||
const tileUrlRef = useRef(tileUrl)
|
||||
tileUrlRef.current = tileUrl
|
||||
const tileLayerRef = useRef<L.TileLayer | null>(null)
|
||||
const glLayerRef = useRef<GlLeafletLayer | null>(null)
|
||||
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
|
||||
const glLayerRef = useRef<BasemapLayer | null>(null)
|
||||
// The vector basemap loads async; a map torn down before it lands must not get one.
|
||||
const cancelledRef = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -379,7 +380,7 @@ function JourneyMap(
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
tileLayerRef.current = null
|
||||
glLayerRef.current?.remove()
|
||||
detachBasemapLayer(glLayerRef.current)
|
||||
glLayerRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
@@ -389,7 +390,7 @@ function JourneyMap(
|
||||
// marker and track it just drew. A vector basemap restyles instead, which also
|
||||
// avoids spending a WebGL context on every theme toggle.
|
||||
useEffect(() => {
|
||||
if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl)
|
||||
if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl)
|
||||
else tileLayerRef.current?.setUrl(tileUrl)
|
||||
}, [tileUrl])
|
||||
|
||||
|
||||
@@ -25,14 +25,33 @@ describe('buildNavItems', () => {
|
||||
})
|
||||
|
||||
describe('splitMobileNav', () => {
|
||||
it('falls back to Dashboard + first two in the bar when there is no config', () => {
|
||||
it('docks the top two enabled defaults when there is no config', () => {
|
||||
for (const cfg of [undefined, { bar: [], more: [] }]) {
|
||||
const s = splitMobileNav(items, cfg)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual(['journey', 'collections'])
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'journey'])
|
||||
// Atlas is still reachable, one tap further under More.
|
||||
expect(ids(s.more)).toEqual(['atlas', 'collections'])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the Vacay/Atlas dock on an instance where Journey is switched off', () => {
|
||||
const s = splitMobileNav([item('dashboard'), item('vacay'), item('atlas')], undefined)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual([])
|
||||
})
|
||||
|
||||
it('fills the free slot from the next default rather than leaving it empty', () => {
|
||||
const s = splitMobileNav([item('dashboard'), item('atlas'), item('journey')], undefined)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'journey', 'atlas'])
|
||||
expect(ids(s.more)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves an account that picked its own dock alone', () => {
|
||||
const s = splitMobileNav(items, { bar: ['vacay', 'atlas'], more: ['journey', 'collections'] })
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual(['journey', 'collections'])
|
||||
})
|
||||
|
||||
it('honours a custom split and order, Dashboard still first', () => {
|
||||
const s = splitMobileNav(items, { bar: ['journey'], more: ['collections', 'vacay', 'atlas'] })
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'journey'])
|
||||
|
||||
@@ -35,8 +35,18 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
|
||||
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
|
||||
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
|
||||
export const MOBILE_NAV_MAX_BAR = 2
|
||||
/** The built-in dock next to Dashboard for an un-customised account; everything else starts under More. */
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'atlas']
|
||||
/**
|
||||
* The built-in dock next to Dashboard for an un-customised account, in priority
|
||||
* order: the first MOBILE_NAV_MAX_BAR of these that are actually enabled take the
|
||||
* slots, everything else starts under More.
|
||||
*
|
||||
* Journey outranks Atlas because a phone in the field wants the journal it writes
|
||||
* to every day, not the map of countries it already visited. Journey ships
|
||||
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
|
||||
* dock it has today. Listing all four known global addons rather than just the
|
||||
* top two also keeps the dock filled when one of them is switched off.
|
||||
*/
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
|
||||
|
||||
export function buildNavItems(
|
||||
globalAddons: { id: string; name: string; icon: string }[],
|
||||
@@ -90,10 +100,10 @@ export interface MobileNavSplit {
|
||||
/**
|
||||
* Resolve the persisted `{ bar, more }` id split against the live nav items.
|
||||
* Dashboard is always pinned first in the bar. An empty/absent config falls back
|
||||
* to the built-in dock (Dashboard + Vacay/Atlas where enabled, everything else
|
||||
* under More) so an un-customised user sees exactly today's layout. Stored ids
|
||||
* that no longer resolve are dropped; newly available items are appended under
|
||||
* More.
|
||||
* to the built-in dock (Dashboard + the top DEFAULT_DOCK_IDS that are enabled,
|
||||
* everything else under More). Stored ids that no longer resolve are dropped;
|
||||
* newly available items are appended under More, so an account that once picked
|
||||
* its own dock keeps it untouched.
|
||||
*/
|
||||
export function splitMobileNav(
|
||||
items: NavItemDef[],
|
||||
@@ -102,16 +112,20 @@ export function splitMobileNav(
|
||||
const dashboard = items.find((i) => i.id === 'dashboard')
|
||||
const head = dashboard ? [dashboard] : []
|
||||
const rest = items.filter((i) => i.id !== 'dashboard')
|
||||
const byId = new Map(rest.map((i) => [i.id, i]))
|
||||
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
|
||||
|
||||
if (!cfg || (cfg.bar.length === 0 && cfg.more.length === 0)) {
|
||||
const dock = pick(DEFAULT_DOCK_IDS).slice(0, MOBILE_NAV_MAX_BAR)
|
||||
const docked = new Set(dock.map((i) => i.id))
|
||||
return {
|
||||
bar: [...head, ...rest.filter((i) => DEFAULT_DOCK_IDS.includes(i.id))],
|
||||
more: rest.filter((i) => !DEFAULT_DOCK_IDS.includes(i.id)),
|
||||
bar: [...head, ...dock],
|
||||
// Store order, not DEFAULT_DOCK_IDS order, so More keeps reading like the
|
||||
// addon list itself.
|
||||
more: rest.filter((i) => !docked.has(i.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map(rest.map((i) => [i.id, i]))
|
||||
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
|
||||
const known = new Set([...cfg.bar, ...cfg.more])
|
||||
|
||||
const barPicked = pick(cfg.bar)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -77,7 +78,7 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Map as MapIcon, Satellite } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
|
||||
@@ -18,7 +19,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
|
||||
// A flat projection, 1000 px per degree: far enough apart that every
|
||||
// booking line clears its declutter floor, which is measured along the
|
||||
// projected line (#2275) rather than read off a canned distanceTo.
|
||||
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
|
||||
x: lng * 1000, y: lat * 1000,
|
||||
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
|
||||
})),
|
||||
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
|
||||
panes: new Map<string, HTMLElement>(),
|
||||
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
|
||||
@@ -221,6 +227,24 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
expect(casing.color).toBe('#c2740a')
|
||||
expect(core.color).toBe('#ff9f0a')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
expect(casing.color).toBe('#0a5cc2')
|
||||
expect(core.color).toBe('#0a84ff')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||
render(<MapView route={null} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
|
||||
@@ -3,7 +3,34 @@ import DOM from 'react-dom'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
|
||||
/**
|
||||
* The drive-time pill for one offered route.
|
||||
*
|
||||
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
|
||||
* road, and it is the same shape the GL renderers build by hand — one look across all
|
||||
* three maps.
|
||||
*/
|
||||
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
|
||||
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
|
||||
const second = note
|
||||
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
|
||||
: ''
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
|
||||
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
|
||||
// one piece of TREK chrome on the map that is not in the app's typeface.
|
||||
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
}
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
@@ -76,9 +103,21 @@ function formatViaDwell(seconds: number): string {
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// A stop that interrupts the drive is drawn as its own small disc, before the photo
|
||||
// branch below ever gets a look at it — the brand logo a fuel search comes back with
|
||||
// is exactly what this replaces. No number badge either, for the same reason the rail
|
||||
// gives it none: it is part of the drive, not one of the day's stops.
|
||||
const service = serviceMarkerHtml(place.stop_type, isSelected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(isSelected)
|
||||
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
|
||||
iconCache.set(cacheKey, icon)
|
||||
return icon
|
||||
}
|
||||
const size = isSelected ? 44 : 36
|
||||
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
|
||||
// escaping stops the attribute breakout but still permits a CSS url().
|
||||
@@ -159,7 +198,13 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string) {
|
||||
function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// One flat disc in the category's colour with its icon, and never the chain's logo.
|
||||
// The brands turned a corridor full of petrol stations into a row of advertisements,
|
||||
// they were unreadable at pin size, and a brand with no logo on file fell back to a
|
||||
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
|
||||
// the signature because the callers still have it; it simply no longer changes anything.
|
||||
void brandWikidata
|
||||
const cached = poiIconCache.get(category)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
@@ -167,7 +212,7 @@ function createPoiIcon(category: string) {
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
@@ -193,6 +238,41 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
|
||||
*
|
||||
* The container rather than the drawn route: a polyline is a real element here but not in
|
||||
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
|
||||
* once the caller projects it onto the routed geometry — one behaviour for all three.
|
||||
*/
|
||||
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onPoiDropOnRoute) return
|
||||
const container = map.getContainer()
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [map, onPoiDropOnRoute])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -277,9 +357,17 @@ interface BoundsControllerProps {
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
}
|
||||
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const awaitingRoute = useRef(false)
|
||||
@@ -336,6 +424,15 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
|
||||
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
if (!focusPoints?.length) return
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints)
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -509,6 +606,9 @@ export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
// One colour pair per entry of `route`, or absent for the blue the route has always
|
||||
// been. Only the road trip passes these, and only while colouring by day is on.
|
||||
routeColors = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
@@ -538,6 +638,18 @@ export const MapView = memo(function MapView({
|
||||
onViewportChange,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
alternativeRoutes,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
}: any) {
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
@@ -547,13 +659,21 @@ export const MapView = memo(function MapView({
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category)}
|
||||
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
eventHandlers={{
|
||||
click: () => onPoiClick?.(poi),
|
||||
// Wired on add rather than in a layout effect: Leaflet builds the icon element
|
||||
// itself, and this is the first moment there is one to attach to.
|
||||
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
|
||||
const el = e.target.getElement()
|
||||
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
||||
@@ -820,7 +940,12 @@ export const MapView = memo(function MapView({
|
||||
// Store flips state synchronously (instant, works offline); a failed save is logged there.
|
||||
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
|
||||
}, [isSatellite, updateSetting])
|
||||
const switcherBottom = hasDayDetail
|
||||
// The day panel is a centred card between the two sidebars, so on the desktop
|
||||
// map it never reaches this pill sitting hard against the left sidebar. Lifting
|
||||
// it by the panel height only made it hop on every collapse and expand, since
|
||||
// --day-panel-h changes while hasDayDetail stays true. On a phone the panel is
|
||||
// full width and does cover the pill, so the lift stays there.
|
||||
const switcherBottom = isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)'
|
||||
|
||||
@@ -872,43 +997,127 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
zoomed right out, and a day's stops merging into one dot is exactly the shape
|
||||
it is being looked at for. Rendered outside the group rather than with the
|
||||
clustering disabled by option, so nothing is left to a plugin's own idea of
|
||||
what "disabled" means. */}
|
||||
{clusterLoosely ? markers : (
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
)}
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
Dashed and thin, over the route rather than under it, because it is the one
|
||||
piece of the line that is not driving: the router snapped the stop to the
|
||||
nearest road and the drive really ends there. Same blue as the route, so it
|
||||
reads as the end of that route and not as a second one. */}
|
||||
{(accessLines ?? []).map((spur, i) => (
|
||||
<Polyline
|
||||
key={`access-${i}`}
|
||||
positions={spur.line}
|
||||
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The offered ways of driving one leg, over the route they replace. Clicking one
|
||||
takes it, which is the same choice the bar above the map offers. */}
|
||||
{/* Every option in blue over a white casing, after Apple Maps: they are all real
|
||||
roads, so the difference is emphasis, not category. Grey was the first attempt
|
||||
and it disappeared on Positron, which is almost entirely greys. */}
|
||||
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
|
||||
const active = activeAlternative === alt.index
|
||||
return [
|
||||
<Polyline
|
||||
key={`alt-${alt.index}-casing`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`alt-${alt.index}`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{
|
||||
color: alt.color,
|
||||
weight: active ? 6 : 4,
|
||||
opacity: active ? 1 : 0.9,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
// The drive time on the road itself, the way Apple labels them.
|
||||
<Marker
|
||||
key={`alt-${alt.index}-label`}
|
||||
position={[alt.at.lat, alt.at.lng]}
|
||||
zIndexOffset={600}
|
||||
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: () => onChooseAlternative(alt.index),
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
]
|
||||
})}
|
||||
|
||||
{/* The handles that shape the drive. After the route so they sit on top of it. */}
|
||||
{roadtripVias ? (
|
||||
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
|
||||
) : null}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
|
||||
@@ -1540,6 +1540,21 @@ describe('MapViewGL', () => {
|
||||
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
|
||||
const routeSource = geoSource()
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
|
||||
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
|
||||
await act(async () => {})
|
||||
|
||||
const { features } = lastData(routeSource)
|
||||
expect(features[0].properties.color).toBe('#ff9f0a')
|
||||
expect(features[0].properties.casing).toBe('#c2740a')
|
||||
// No colour means the day route's own blue, which the layer paint falls back to.
|
||||
expect(features[1].properties.color).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
|
||||
const gpxSource = geoSource()
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
|
||||
@@ -1855,4 +1870,168 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function))
|
||||
expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => {
|
||||
// Same drift the planned-place pins had: a library Marker repositions on every
|
||||
// `move` event — one per pointer sample — while the canvas draws once a frame, so
|
||||
// the suggestions swam over the map during a drag and snapped back on release.
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
pois={[{ osm_id: 'node:1', name: 'Aral', lat: 48.1, lng: 2.1, category: 'fuel', source: 'openstreetmap' } as never]}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
expect(layer.children.length).toBe(1)
|
||||
const pin = layer.firstElementChild as HTMLElement
|
||||
expect(pin.style.transform).toContain('translate(100px, 80px)')
|
||||
|
||||
glMap.project.mockReturnValue({ x: 140, y: 60 })
|
||||
act(() => { mapHandler('render')() })
|
||||
|
||||
expect(pin.style.transform).toContain('translate(140px, 60px)')
|
||||
})
|
||||
|
||||
// A via handle is a DOM element, and a pointerdown belongs to the element it happened
|
||||
// on. Rebuilding the handles on an unrelated render pulled that element out from under
|
||||
// the pointer, so a freshly placed via lost its first drag — the re-route it triggered
|
||||
// landed about a second later, which is exactly when somebody reaches for it.
|
||||
it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [stored] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
expect(handle).toBeTruthy()
|
||||
|
||||
// The same via in a fresh object, with a fresh callback: what every render of the
|
||||
// planner hands down while the day re-routes.
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
expect(layer.firstElementChild).toBe(handle)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored, lat: 48.9 }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
// The other half of the contract: holding the element still must not mean holding it
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => {
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// Endpoints 50 px apart, under the 80 px car floor, so the straight hop would
|
||||
// hide. The road between them loops out 0.3° (300 px) and back, which is what
|
||||
// is actually drawn — and what the person was missing on their map.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
unmount()
|
||||
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(1)
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => {
|
||||
// From and to sit 20 px apart; the stop is 400 px away, so the two legs
|
||||
// together draw 800 px of line.
|
||||
renderOverlay({
|
||||
reservations: [booking({
|
||||
id: 8, type: 'car',
|
||||
endpoints: [
|
||||
endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }),
|
||||
endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }),
|
||||
endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }),
|
||||
],
|
||||
})],
|
||||
})
|
||||
expect(lines()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
// Every point of the road projects within 30 px: nothing worth drawing yet.
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => {
|
||||
renderOverlay({ reservations: [booking({ endpoints: undefined })] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const ENDPOINT_PANE = 'reservation-endpoints'
|
||||
@@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
@@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
}, [reservations])
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
|
||||
return items.filter(item => {
|
||||
// A transit journey draws its real rail/bus alignment, not a straight from->to
|
||||
// line, so the endpoint-proximity declutter (which exists to hide tiny no-op
|
||||
// straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one
|
||||
// with no other places to tighten the map onto — hides the whole route (#1570).
|
||||
if (item.transitSegs.length > 0) return true
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return fromPx.distanceTo(toPx) >= minPx
|
||||
// Measured along what is drawn, not between the ends: a routed drive can
|
||||
// cover half the screen while its endpoints sit close together (#2275).
|
||||
return hopIsVisible(item.type, linesFor(item, roadRoutes), project)
|
||||
})
|
||||
}, [items, zoom, map])
|
||||
}, [items, zoom, map, roadRoutes])
|
||||
|
||||
const labelVisibleIds = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const item of visibleItems) {
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
||||
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
|
||||
}
|
||||
return set
|
||||
}, [visibleItems, zoom, map])
|
||||
@@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map((seg, segIdx) => (
|
||||
return linesFor(item, roadRoutes).map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The handles that shape a drive.
|
||||
*
|
||||
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
|
||||
* like one would read as a place on the itinerary. Dragging it redraws the route through
|
||||
* the new spot, which is the whole interaction — the same one a paper map gets when you
|
||||
* put a finger on the road you actually want to take.
|
||||
*/
|
||||
const VIA_ICON = L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="
|
||||
display:block;width:12px;height:12px;border-radius:9999px;
|
||||
background:#0a84ff;border:2.5px solid #ffffff;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
|
||||
"></span>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
})
|
||||
|
||||
interface RoadtripViaMarkersProps {
|
||||
viasByDay: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const { t } = useTranslation()
|
||||
const vias = useStableVias(viasByDay)
|
||||
// Read through a ref so the handlers below can stay the same objects across renders.
|
||||
// Both callbacks are rebuilt on every render of the planner, and react-leaflet compares
|
||||
// its props by identity: a fresh `eventHandlers` unbinds and rebinds every listener,
|
||||
// and a fresh `position` array calls `setLatLng` on every marker. Doing either while a
|
||||
// drag is in flight is how a freshly placed via lost its first drag.
|
||||
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,
|
||||
position: [via.lat, via.lng] as [number, number],
|
||||
handlers: {
|
||||
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
||||
const at = e.target.getLatLng()
|
||||
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
},
|
||||
// Right-click rather than a delete handle: a 12px dot has no room for one,
|
||||
// and the same gesture removes things elsewhere on the map.
|
||||
contextmenu: (e: { originalEvent: MouseEvent }) => {
|
||||
e.originalEvent.preventDefault()
|
||||
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
},
|
||||
},
|
||||
})), [vias])
|
||||
|
||||
if (!markers.length || !zoomedIn) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map(({ via, position, handlers }) => (
|
||||
<Marker
|
||||
key={`via-${via.id}`}
|
||||
position={position}
|
||||
icon={VIA_ICON}
|
||||
draggable={draggable}
|
||||
zIndexOffset={400}
|
||||
eventHandlers={handlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
{t('roadtrip.via.hint')}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
generateCoMapsUrl,
|
||||
parsePluginProfile,
|
||||
withHotelBookends,
|
||||
calculateAlternatives,
|
||||
} from './RouteCalculator'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
|
||||
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
|
||||
// demo ignored the profile in the URL, so walking routes followed the road network.
|
||||
const FOSSGIS = {
|
||||
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||
@@ -46,7 +47,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -56,7 +57,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
|
||||
)
|
||||
)
|
||||
@@ -66,7 +67,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(800, 360))
|
||||
)
|
||||
)
|
||||
@@ -78,7 +79,7 @@ describe('calculateRoute', () => {
|
||||
const distance = 5000
|
||||
const osrmDuration = 999
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/walking/:coords`, () =>
|
||||
http.get(`${FOSSGIS.walking}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
|
||||
)
|
||||
)
|
||||
@@ -90,7 +91,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({}, { status: 500 })
|
||||
)
|
||||
)
|
||||
@@ -99,7 +100,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
)
|
||||
)
|
||||
@@ -108,7 +109,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -128,7 +129,7 @@ describe('calculateSegments', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
@@ -325,7 +326,7 @@ describe('parsePluginProfile', () => {
|
||||
describe('calculateRoute profiles', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
|
||||
http.get(`${FOSSGIS.cycling}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
|
||||
)
|
||||
)
|
||||
@@ -340,12 +341,12 @@ describe('calculateRoute profiles', () => {
|
||||
|
||||
describe('calculateSegments failures', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
|
||||
})
|
||||
})
|
||||
@@ -613,3 +614,364 @@ describe('generateCoMapsUrl', () => {
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a self-hosted routing engine', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
const useOwnRouter = (base: string) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('osrm.example.org')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
|
||||
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
|
||||
|
||||
expect(asked).toContain('/route/v1/foot/')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
|
||||
useOwnRouter('https://osrm.example.org///')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).not.toContain('//route')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
|
||||
useOwnRouter(' ')
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('routing.openstreetmap.de')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAlternatives', () => {
|
||||
const twoRoutes = {
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
// The direct one, straight down the middle.
|
||||
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
|
||||
// A detour that swings well to the east before rejoining.
|
||||
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
|
||||
],
|
||||
}
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(twoRoutes)
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toContain('alternatives=3')
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0].distance).toBe(100000)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
// The first is what the router gives anyway, so it needs no pinning point.
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
// The second diverges at its eastern swing — saving that as a via forces this road.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
/** The router's own answer, and whatever it says when a road class is left out. */
|
||||
const withExclusions = (byExclude: Record<string, unknown>) =>
|
||||
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]] })
|
||||
const answer = byExclude[exclude]
|
||||
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
|
||||
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
|
||||
// the identical line a second time would be a choice that is not a choice.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
|
||||
// OSRM answers most long legs with exactly one route, which used to mean the picker
|
||||
// said there was no other way — while a perfectly good slower road existed.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
// Pinnable like any other offer: the point where it leaves the direct line.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toEqual(['motorway', 'toll'])
|
||||
expect(routes[1].avoids).toBe('toll')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
|
||||
// The foot profile has no excludable classes; asking earns an InvalidOptions for
|
||||
// nothing, and a walk has no motorway to avoid in the first place.
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
|
||||
|
||||
expect(asked).toEqual([])
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => {
|
||||
// Both public hosts TREK ships with answer 400 here, so without this every long leg
|
||||
// pays two extra requests against a one-per-second limit to be refused twice.
|
||||
const asked: string[] = []
|
||||
server.use(http.get('*/route/v1/driving/*', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
asked.push(exclude)
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}))
|
||||
|
||||
await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
|
||||
const afterFirst = asked.length
|
||||
await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
|
||||
|
||||
expect(afterFirst).toBe(1)
|
||||
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
|
||||
// route, while the drive really starts somewhere else.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [
|
||||
{ location: [10.02, 53.51], distance: 812.5 },
|
||||
{ location: [13.0, 52.0], distance: 4 },
|
||||
],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toEqual([
|
||||
{ asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
|
||||
{ asked: [52, 13], at: [52.0, 13.0], meters: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
// Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
|
||||
// real number rather than the whole answer being discarded.
|
||||
expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
|
||||
expect(r.snapped?.[0].meters).toBeLessThan(1200)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
|
||||
// All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
|
||||
// bad entry would hang the spur off the wrong stop.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toBeUndefined()
|
||||
expect(r.coordinates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
|
||||
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
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
|
||||
@@ -14,6 +16,112 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||
}
|
||||
|
||||
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
|
||||
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'driving',
|
||||
walking: 'foot',
|
||||
cycling: 'bike',
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to ask for a route.
|
||||
*
|
||||
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
|
||||
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
|
||||
* `routing_base_url` and everything routes against that instead; the server has to name
|
||||
* the same origin in its connect-src, or the browser blocks the requests silently.
|
||||
*
|
||||
* A configured base is expected to serve the standard OSRM layout,
|
||||
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
|
||||
*/
|
||||
/**
|
||||
* A configured base URL without its trailing slashes.
|
||||
*
|
||||
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
|
||||
* slashes, and the value comes from an instance setting somebody types. Linear either
|
||||
* way in practice, but the regex is the shape a scanner is right to flag.
|
||||
*/
|
||||
function withoutTrailingSlashes(value: string): string {
|
||||
let end = value.length
|
||||
while (end > 0 && value[end - 1] === '/') end -= 1
|
||||
return value.slice(0, end)
|
||||
}
|
||||
|
||||
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
||||
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
if (!configured) return OSRM_PROFILE_BASE[profile]
|
||||
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".
|
||||
*
|
||||
* The public OSRM instances allow roughly one request a second and answer 429 above that.
|
||||
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
|
||||
* try again" from "these two points are not connected by road" — and a road trip, which
|
||||
* asks for every leg of every day, hits the first case constantly while the second is
|
||||
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
|
||||
*/
|
||||
export class RoutingRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
||||
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
|
||||
this.name = 'RoutingRefusedError'
|
||||
}
|
||||
|
||||
/** True when waiting and asking again is the right response. */
|
||||
get isRateLimit(): boolean {
|
||||
return this.status === 429 || this.status === 503
|
||||
}
|
||||
}
|
||||
|
||||
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
|
||||
function retryAfterMs(response: Response): number | null {
|
||||
const raw = response.headers.get('Retry-After')
|
||||
if (!raw) return null
|
||||
const seconds = Number(raw)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
|
||||
const at = Date.parse(raw)
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
@@ -45,11 +153,11 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
const response = await routedFetch(url, signal, 'route', profile, waypoints)
|
||||
if (!response.ok) {
|
||||
throw new Error('Route could not be calculated')
|
||||
throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -252,10 +360,10 @@ export async function calculateSegments(
|
||||
if (!waypoints || waypoints.length < 2) return []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -285,23 +393,55 @@ export async function calculateSegments(
|
||||
*/
|
||||
export async function calculateRouteWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
|
||||
{ signal, profile = 'driving', tripId, dayId, avoid = [] }: {
|
||||
signal?: AbortSignal
|
||||
profile?: RouteProfileKey
|
||||
tripId?: number | string | null
|
||||
dayId?: number | null
|
||||
/** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */
|
||||
avoid?: readonly AvoidClass[]
|
||||
} = {}
|
||||
): Promise<RouteWithLegs> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort()
|
||||
: []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
// The cached result carries formatted leg distances, so the active distance unit is
|
||||
// part of the key — otherwise switching km↔mi would return stale text (#1300).
|
||||
// A plugin route is trip-/day-specific (it may return different charging stops for
|
||||
// the same coordinates on a different day), so its key includes tripId/dayId;
|
||||
// the built-in OSRM profiles are context-free and leave those out.
|
||||
//
|
||||
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
|
||||
// turning the switch on returns the tolled route out of cache in under a millisecond,
|
||||
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
|
||||
// way is worse: this Map is shared with the ordinary day planner, the studio and the
|
||||
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
|
||||
// under the plain key would hand them a road nobody drives and a second engine's
|
||||
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
|
||||
// also separates the two engines.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
|
||||
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
|
||||
// day drawn rather than blank, and `avoidance` stays absent on that result so the
|
||||
// rail can say the avoidance did not happen instead of implying it did.
|
||||
if (routed) {
|
||||
cacheRoute(cacheKey, routed)
|
||||
return routed
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
|
||||
// and normalizes its answer; null means the provider failed or refused, and the
|
||||
// throw makes callers fall back to straight lines exactly like an OSRM outage.
|
||||
@@ -344,10 +484,13 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
|
||||
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
// Written as literals rather than narrowing `profile`: its type is an open string union
|
||||
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
||||
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)
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -374,13 +517,106 @@ export async function calculateRouteWithLegs(
|
||||
}
|
||||
)
|
||||
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||
routeCache.set(cacheKey, result)
|
||||
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)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain from the second engine, weighted away from some road classes.
|
||||
*
|
||||
* Shaped exactly like the OSRM answer above so the callers never branch on which engine
|
||||
* spoke — same leg list, same formatted text, same snap positions. What it adds is
|
||||
* `avoidance`: what was asked for against what the road actually turned out to be, which
|
||||
* is the only honest way to label a weighting that can fail.
|
||||
*
|
||||
* Null rather than a throw when it cannot answer, so the caller falls through to OSRM
|
||||
* and the day stays drawn.
|
||||
*/
|
||||
async function routeAvoidingWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
avoid: AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteWithLegs | null> {
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: waypoints.length,
|
||||
km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0),
|
||||
}
|
||||
const run = await valhallaRun(waypoints, 'driving', avoid, signal)
|
||||
// Counted by hand because the adapter speaks to a different engine with a different
|
||||
// verb, and the counter exists to answer "how much routing does this instance do" —
|
||||
// an engine it cannot see is exactly the traffic that would go missing from it.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
|
||||
if (!run) return null
|
||||
|
||||
const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from,
|
||||
to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(leg.distance / (5000 / 3600)),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
durationText: formatDuration(leg.duration),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
coordinates: run.total.coordinates,
|
||||
distance: run.total.distance,
|
||||
duration: run.total.duration,
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The snap positions out of an OSRM answer, or undefined if it did not describe them.
|
||||
*
|
||||
* OSRM reports this in every response and TREK has thrown it away since the first route
|
||||
* was drawn. `distance` is the straight line from the coordinate we sent to the road it
|
||||
* used; it is recomputed from the two points when the field is missing or nonsense, so a
|
||||
* mirror that trims its answers still produces a usable gap rather than a wrong one.
|
||||
*
|
||||
* All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
|
||||
* and one bad entry would put a spur on the wrong stop.
|
||||
*/
|
||||
function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
|
||||
const raw = (data as { waypoints?: unknown })?.waypoints
|
||||
if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
|
||||
const out: SnappedWaypoint[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const loc = (raw[i] as { location?: unknown })?.location
|
||||
if (!Array.isArray(loc) || loc.length < 2) return undefined
|
||||
const [lng, lat] = loc as [number, number]
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
|
||||
const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const at: [number, number] = [lat, lng]
|
||||
const reported = (raw[i] as { distance?: unknown })?.distance
|
||||
const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
|
||||
? reported
|
||||
: haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
|
||||
out.push({ asked, at, meters })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function getDistanceUnit(): DistanceUnit {
|
||||
@@ -403,3 +639,284 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as OSRM offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
* OSRM's own alternative search is conservative: it only offers a second road when the
|
||||
* detour is short enough and shares little enough with the first, so on a long motorway
|
||||
* run it usually answers with exactly one route — while a perfectly good, slower way over
|
||||
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
|
||||
* a class asks a different question and reliably produces that road.
|
||||
*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* How far two lines may stray from each other and still be the same road, in kilometres.
|
||||
*
|
||||
* Parallel carriageways, a different slip road onto the same motorway and the noise
|
||||
* between two engines' idea of where a road is all sit well under this. A way somebody
|
||||
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
|
||||
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
|
||||
*/
|
||||
const SAME_ROAD_KM = 0.5
|
||||
|
||||
/**
|
||||
* Two lines are the same road.
|
||||
*
|
||||
* Length and duration used to decide this, and stopped being enough when a second engine
|
||||
* started answering. Valhalla prices roads differently — measured over ten European legs
|
||||
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
|
||||
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
|
||||
* Two identical blue lines on the map is exactly what that looks like.
|
||||
*
|
||||
* The geometry does not care which engine drew it, so it decides whenever there is one.
|
||||
* The numbers stay as the fallback for a line with no shape to compare.
|
||||
*/
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
const stray = furthestFrom(b.coordinates, a.coordinates)
|
||||
if (stray) return stray.km < SAME_ROAD_KM
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that have already answered "I do not do exclude", by base URL.
|
||||
*
|
||||
* Both public hosts TREK ships with reject the parameter outright with HTTP 400: the
|
||||
* upstream car profile is built without excludable classes, and only a self-hosted OSRM
|
||||
* built through the MLD pipeline supports it. Without this, every long leg on a default
|
||||
* install pays two extra requests to be told twice that it cannot have what it asked for,
|
||||
* against a host that allows one request per second.
|
||||
*
|
||||
* The first real attempt is the probe; there is no separate one. Only a 400 is recorded,
|
||||
* because only a 400 is about the host rather than about this particular question: a 429
|
||||
* is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no
|
||||
* way round the motorway while the next one might.
|
||||
*
|
||||
* Per base URL rather than global, so pointing the instance at an own router that does
|
||||
* support it starts asking again without a reload.
|
||||
*/
|
||||
const excludeUnsupported = new Set<string>()
|
||||
|
||||
/**
|
||||
* One route with a road class left out, or null when the router will not or cannot.
|
||||
*
|
||||
* Every failure is a null: a rate limit answers 429, and a leg with no way round the
|
||||
* motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
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,
|
||||
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])
|
||||
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.
|
||||
if (response.status === 400) excludeUnsupported.add(base)
|
||||
return null
|
||||
}
|
||||
const data = await response.json()
|
||||
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
|
||||
if (!route?.geometry?.coordinates?.length) return null
|
||||
return {
|
||||
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
divergence: null,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, not one day.
|
||||
*
|
||||
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
|
||||
* asking for a whole day would mean splitting it into one request per leg — which is the
|
||||
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
|
||||
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
|
||||
*
|
||||
* `divergence` is the point on each alternative that lies furthest from the default route.
|
||||
* It is what makes a choice persistable: saving that point as a via forces the router back
|
||||
* onto this road on every future request, without storing a polyline that would go stale
|
||||
* with the next OSM update.
|
||||
*/
|
||||
export async function calculateAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
|
||||
): 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])
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
|
||||
|
||||
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
|
||||
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
}))
|
||||
|
||||
// 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.
|
||||
//
|
||||
// This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP
|
||||
// 400 on both public hosts, whose car profile is built without excludable classes. It
|
||||
// now asks Valhalla first, where avoidance is a runtime weighting, and only falls back
|
||||
// to OSRM's `exclude` for an instance running its own MLD-built router.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(from, to, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first route is what the router would have given anyway; the others are measured
|
||||
// against it so each one can be pinned by the point that makes it different.
|
||||
const [primary, ...rest] = routes
|
||||
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
|
||||
return routes
|
||||
}
|
||||
|
||||
/** 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 (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
|
||||
}
|
||||
if (!best) return null
|
||||
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { ...best, km }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from './TripRouteOverview'
|
||||
import type { TripRouteOverview } from './useTripRouteOverview'
|
||||
|
||||
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
|
||||
days: [
|
||||
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
|
||||
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
|
||||
],
|
||||
lines: [], lineColors: [], segments: [], focusPoints: [],
|
||||
totalDistance: 222500, totalDuration: 10800, loading: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('TripRouteOverviewPill', () => {
|
||||
it('FE-MAP-TROU-001: says which way it will take you, and reports its state', () => {
|
||||
const onToggle = vi.fn()
|
||||
const { rerender } = render(<TripRouteOverviewPill active={false} onToggle={onToggle} />)
|
||||
|
||||
const button = screen.getByTestId('trip-overview-pill')
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(button).toHaveAttribute('aria-label', 'map.overview.show')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<TripRouteOverviewPill active onToggle={onToggle} />)
|
||||
expect(screen.getByTestId('trip-overview-pill')).toHaveAttribute('aria-label', 'map.overview.hide')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TripRouteOverviewPanel', () => {
|
||||
it('FE-MAP-TROU-002: leads with the trip total in the reader\'s own unit', () => {
|
||||
const { rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(screen.getByText('222.5 km')).toBeInTheDocument()
|
||||
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="imperial" />)
|
||||
expect(screen.getByText('138.3 mi')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-003: names every day, its distance and how it is travelled', () => {
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
|
||||
expect(screen.getByText('dayplan.dayN')).toBeInTheDocument()
|
||||
// A day with a title is named by it rather than by its number.
|
||||
expect(screen.getByText('Coast road')).toBeInTheDocument()
|
||||
expect(screen.getByText('82 km')).toBeInTheDocument()
|
||||
expect(screen.getByText('140.5 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-004: marks the total as partial while legs are still coming in', () => {
|
||||
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview({ loading: true })} unit="metric" />)
|
||||
expect(container.textContent).toContain('…')
|
||||
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(container.textContent).not.toContain('…')
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-005: a day row picks that day when the shell offers it', () => {
|
||||
const onSelectDay = vi.fn()
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" selectedDayId={2} onSelectDay={onSelectDay} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Coast road'))
|
||||
expect(onSelectDay).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-006: read-only shells get rows, not buttons', () => {
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-008: hugs its rows, and takes the cap the shell gives it', () => {
|
||||
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
const card = () => screen.getByTestId('trip-overview-panel')
|
||||
|
||||
expect(card().style.width).toBe('fit-content')
|
||||
expect(card().style.maxWidth).toBe('320px')
|
||||
|
||||
// The phone shell asks for a narrower card so the map keeps the space.
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" maxWidth={240} />)
|
||||
expect(card().style.maxWidth).toBe('240px')
|
||||
expect(container).not.toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-007: draws nothing at all for a trip with no routed day', () => {
|
||||
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
|
||||
/**
|
||||
* Toggle for the whole-trip route overview (#1736). Same frosted shell as the other
|
||||
* map controls, so it lines up with the compass and the layer switcher.
|
||||
*/
|
||||
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const label = active ? t('map.overview.hide') : t('map.overview.show')
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
data-testid="trip-overview-pill"
|
||||
className={active ? 'text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', padding: 0,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Route size={17} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's stages and its total distance, read off the overview the map is drawing.
|
||||
*
|
||||
* One component for both shells: the phone and the desktop place it differently but the
|
||||
* rows are the same rows, and a second copy of them is a second thing to keep in step.
|
||||
*/
|
||||
export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelectDay, maxWidth = 320 }: {
|
||||
overview: Overview
|
||||
unit: DistanceUnit
|
||||
selectedDayId?: number | null
|
||||
onSelectDay?: (dayId: number) => void
|
||||
/** How wide the card may grow before day names start to ellipsize. */
|
||||
maxWidth?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!overview.days.length) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="trip-overview-panel"
|
||||
style={{
|
||||
pointerEvents: 'auto', borderRadius: 14, overflow: 'hidden',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
// Sized to its rows rather than to the space available: a trip of short day
|
||||
// names left a band of empty card over the map, which on a phone is most of
|
||||
// what there is to look at.
|
||||
width: 'fit-content', maxWidth,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '10px 14px 8px' }}>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{t('map.overview.total')}
|
||||
</span>
|
||||
{/* Same tier as the label beside it: the two read as one line rather than as a
|
||||
heading with a number stuck under it. */}
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
|
||||
{formatDistance(overview.totalDistance / 1000, unit)}
|
||||
{/* Still routing: the number is a partial sum, and saying so beats a total
|
||||
that silently grows while you read it. */}
|
||||
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
|
||||
{overview.days.map(day => {
|
||||
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
|
||||
const row = (
|
||||
<>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
|
||||
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
|
||||
{day.modes.map(mode => {
|
||||
const Icon = profileIcon(mode)
|
||||
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
|
||||
})}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatDistance(day.distance / 1000, unit)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const style = {
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 14px', textAlign: 'left' as const,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent',
|
||||
border: 'none',
|
||||
}
|
||||
return onSelectDay ? (
|
||||
<button key={day.dayId} type="button" onClick={() => onSelectDay(day.dayId)} style={{ ...style, cursor: 'pointer' }}>
|
||||
{row}
|
||||
</button>
|
||||
) : (
|
||||
<div key={day.dayId} style={style}>{row}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import type * as L from 'leaflet'
|
||||
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap'
|
||||
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import {
|
||||
VectorBasemap,
|
||||
attachVectorBasemap,
|
||||
detachBasemapLayer,
|
||||
hideLabelLayers,
|
||||
restyleBasemap,
|
||||
type BasemapLayer,
|
||||
type GlLeafletLayer,
|
||||
} from './VectorBasemap'
|
||||
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION, RASTER_FALLBACK_TILE_URL } from '../../constants/mapDefaults'
|
||||
|
||||
const addAttribution = vi.fn()
|
||||
/** Only the two things the component touches; casting keeps the mock honest about that. */
|
||||
@@ -20,7 +29,16 @@ const leafletMap = asMap({ attributionControl: { addAttribution } })
|
||||
/** A map built without an attribution control, like the atlas. */
|
||||
const bareMap = asMap({})
|
||||
|
||||
vi.mock('react-leaflet', () => ({ useMap: () => leafletMap }))
|
||||
// TileLayer as well as useMap: the component renders one when the browser has no
|
||||
// WebGL, and jsdom is exactly such a browser.
|
||||
vi.mock('react-leaflet', () => ({
|
||||
useMap: () => leafletMap,
|
||||
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-basemap" data-url={url} />,
|
||||
}))
|
||||
|
||||
// jsdom has no WebGL, so without this every case below would take the raster
|
||||
// branch. The suite is about the GL wiring; the raster branch has its own cases.
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
|
||||
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
|
||||
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
||||
const maplibreGL = vi.fn(() => {
|
||||
@@ -201,3 +219,91 @@ describe('hideLabelLayers', () => {
|
||||
expect(() => hideLabelLayers(layer)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A browser that will not give MapLibre a WebGL context (#2288).
|
||||
*
|
||||
* Hardware acceleration off, a blocklisted driver, a VM or a remote desktop all
|
||||
* land here. Before the probe, the throw came out of an async effect nobody could
|
||||
* catch, and the layer it left behind took the page down on the way out.
|
||||
*/
|
||||
describe('VectorBasemap without WebGL', () => {
|
||||
const noWebGL = vi.mocked(hasWebGL)
|
||||
|
||||
/** A map real Leaflet will accept, which the raster stand-in needs. */
|
||||
const rasterMap = () => asMap({ addLayer: vi.fn(), attributionControl: { addAttribution } })
|
||||
|
||||
beforeEach(() => {
|
||||
noWebGL.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-013: draws raster tiles and never asks for the maplibre chunk', async () => {
|
||||
// Not loading it is half the point: about a megabyte for a browser that could
|
||||
// never have drawn it.
|
||||
const { findByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
|
||||
const tiles = await findByTestId('raster-basemap')
|
||||
expect(tiles.getAttribute('data-url')).toBe(RASTER_FALLBACK_TILE_URL)
|
||||
expect(maplibreGL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-014: unmounting is quiet, with nothing to take off the map', () => {
|
||||
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
// The crash in #2288 came from teardown, not from the failed attach.
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-015: the imperative maps get the raster stand-in in their ref', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
const map = rasterMap()
|
||||
|
||||
await attachVectorBasemap(map, OFM_POSITRON, ref, () => false)
|
||||
|
||||
expect(maplibreGL).not.toHaveBeenCalled()
|
||||
expect(ref.current).not.toBeNull()
|
||||
// OpenStreetMap rather than OpenFreeMap: different tiles, different credit.
|
||||
expect(addAttribution).toHaveBeenCalledWith(expect.stringContaining('openstreetmap.org/copyright'))
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-016: a map torn down mid-probe still gets nothing', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => true)
|
||||
expect(ref.current).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-017: the raster stand-in ignores a restyle and a label sweep', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => false)
|
||||
|
||||
// Both are GL-only operations. Called on raster tiles they must do nothing
|
||||
// rather than reach for a maplibre map that is not there.
|
||||
expect(() => restyleBasemap(ref.current, OFM_DARK)).not.toThrow()
|
||||
expect(() => hideLabelLayers(ref.current!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detachBasemapLayer', () => {
|
||||
it('FE-COMP-VECBM-018: a GL layer whose context never came off cleanly anyway', () => {
|
||||
// This is the crash from #2288 in one line: maplibre-gl-leaflet's onRemove
|
||||
// calls this._glMap.remove() without asking whether there is one, and after a
|
||||
// refused context there is not. It ran inside a React cleanup, so the throw
|
||||
// reached a boundary and replaced the page.
|
||||
const remove = vi.fn(function (this: { _glMap?: { remove: () => void } }) {
|
||||
this._glMap!.remove()
|
||||
})
|
||||
const halfBuilt = { getMaplibreMap: () => undefined, remove } as unknown as GlLeafletLayer
|
||||
|
||||
expect(() => detachBasemapLayer(halfBuilt)).not.toThrow()
|
||||
expect(remove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-019: a layer that throws for any other reason is swallowed too', () => {
|
||||
// Unmounting a map is never allowed to fail because its basemap did.
|
||||
const layer = { remove: vi.fn(() => { throw new Error('boom') }) } as unknown as BasemapLayer
|
||||
expect(() => detachBasemapLayer(layer)).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-020: nothing to detach is not an error', () => {
|
||||
expect(() => detachBasemapLayer(null)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import type * as L from 'leaflet'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TileLayer, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { attributionForTile } from '../../constants/mapDefaults'
|
||||
import {
|
||||
RASTER_FALLBACK_MAX_ZOOM,
|
||||
RASTER_FALLBACK_TILE_URL,
|
||||
attributionForTile,
|
||||
} from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
||||
|
||||
/**
|
||||
* What a map ended up drawing with. Every caller keeps one ref for both kinds, so
|
||||
* there is no code path that knows only about the GL layer and falls over the
|
||||
* raster stand-in the moment a browser has no WebGL (#2288).
|
||||
*/
|
||||
export type BasemapLayer = GlLeafletLayer | L.TileLayer
|
||||
|
||||
/** A GL layer rather than the raster stand-in. Prototype method, so `in` finds it. */
|
||||
function isGlLayer(layer: BasemapLayer): layer is GlLeafletLayer {
|
||||
return 'getMaplibreMap' in layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the layer factory.
|
||||
*
|
||||
@@ -28,11 +46,105 @@ async function loadLayerFactory() {
|
||||
return bridge.maplibreGL
|
||||
}
|
||||
|
||||
/** The factory, or null when its chunk never arrived. A dead chunk is not the basemap's problem. */
|
||||
async function loadLayerFactoryOrNull() {
|
||||
try {
|
||||
return await loadLayerFactory()
|
||||
} catch (err) {
|
||||
// A chunk gone after a deploy is the global handler's case and heals itself
|
||||
// with one reload; here it only means there is no GL layer to attach.
|
||||
if (isChunkLoadError(err)) reloadOnceForChunk()
|
||||
else console.warn('[basemap] the maplibre chunk did not load', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Credit the basemap on a map that has an attribution control; a no-op on one that does not. */
|
||||
function creditBasemap(map: L.Map, style: string): void {
|
||||
map.attributionControl?.addAttribution(attributionForTile(style))
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a half-built GL layer safe to take off a map.
|
||||
*
|
||||
* maplibre-gl-leaflet's onRemove calls `this._glMap.remove()` without asking
|
||||
* whether there is one, and after a refused WebGL context there is not. That
|
||||
* second throw is what turned a missing basemap into a crash: it lands in a React
|
||||
* cleanup, where a boundary picks it up and replaces the page (#2288). A no-op
|
||||
* stand-in lets the bridge run the rest of its own teardown, the pane child and
|
||||
* the zoom-animation listener, and skip only the line that cannot work. A healthy
|
||||
* layer already has a GL map and is left alone.
|
||||
*/
|
||||
function disarmGlLayer(layer: GlLeafletLayer): void {
|
||||
const internals = layer as unknown as { _glMap?: { remove: () => void } | null }
|
||||
if (!internals._glMap) internals._glMap = { remove: () => {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang a GL layer into a Leaflet map and say whether it took.
|
||||
*
|
||||
* maplibre-gl builds its WebGL context inside the Map constructor, which Leaflet
|
||||
* reaches through onAdd, so `addTo` is where a browser without WebGL throws. The
|
||||
* probe catches nearly every case; what is left is a context budget that ran out
|
||||
* between probe and attach, which is real on a page holding several maps. Either
|
||||
* way the layer has to come back off: Leaflet registers it before onAdd runs, so
|
||||
* a broken one left behind is handed to the retile effect and to every later
|
||||
* map.remove().
|
||||
*/
|
||||
function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolean {
|
||||
try {
|
||||
layer.addTo(map)
|
||||
} catch (err) {
|
||||
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
|
||||
detachBasemapLayer(layer)
|
||||
return false
|
||||
}
|
||||
creditBasemap(map, style)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The raster stand-in, attached imperatively for the maps that build Leaflet
|
||||
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
|
||||
* below instead, the way they already draw a user's own raster template.
|
||||
*/
|
||||
function attachRasterFallback(map: L.Map): L.TileLayer {
|
||||
const layer = L.tileLayer(RASTER_FALLBACK_TILE_URL, {
|
||||
maxZoom: RASTER_FALLBACK_MAX_ZOOM,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
} as L.TileLayerOptions)
|
||||
layer.addTo(map)
|
||||
creditBasemap(map, RASTER_FALLBACK_TILE_URL)
|
||||
return layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a basemap off its map. Never throws: unmounting a map is not allowed to
|
||||
* fail because its basemap did (#2288).
|
||||
*/
|
||||
export function detachBasemapLayer(layer: BasemapLayer | null | undefined): void {
|
||||
if (!layer) return
|
||||
if (isGlLayer(layer)) disarmGlLayer(layer)
|
||||
try {
|
||||
layer.remove()
|
||||
} catch (err) {
|
||||
console.warn('[basemap] teardown failed, leaving the layer where it is', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retile in place. Swapping the layer would drop a WebGL context per theme
|
||||
* toggle, and browsers cap those in the low teens.
|
||||
*
|
||||
* The raster stand-in has nothing to swap: it draws one template, and there is no
|
||||
* keyless dark OSM raster to switch to, so a theme change leaves it alone rather
|
||||
* than refetching the same tiles.
|
||||
*/
|
||||
export function restyleBasemap(layer: BasemapLayer | null | undefined, style: string): void {
|
||||
if (!layer || !isGlLayer(layer)) return
|
||||
layer.getMaplibreMap()?.setStyle(style)
|
||||
}
|
||||
|
||||
/**
|
||||
* A MapLibre vector style as the basemap of a Leaflet map.
|
||||
*
|
||||
@@ -40,21 +152,37 @@ function creditBasemap(map: L.Map, style: string): void {
|
||||
* watermark unless the operator has requested a key by mail, so the basemap of
|
||||
* every Leaflet map here is a style document rather than a tile template.
|
||||
* maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which
|
||||
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers
|
||||
* and the panes they live in are untouched, and Leaflet's CSS puts
|
||||
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers and
|
||||
* the panes they live in are untouched, and Leaflet's CSS puts
|
||||
* `pointer-events: none` on the layer's canvas, so clicks fall through to them.
|
||||
*
|
||||
* WebGL is not a given, though. Hardware acceleration switched off, a blocklisted
|
||||
* driver, a VM or a remote desktop all end with maplibre-gl throwing out of its
|
||||
* constructor, so the browser is asked first and gets raster tiles when the answer
|
||||
* is no (#2288). Asking is also what keeps the maplibre chunk off the wire for a
|
||||
* browser that could never have used it.
|
||||
*/
|
||||
export function VectorBasemap({ style }: { style: string }): null {
|
||||
export function VectorBasemap({ style }: { style: string }) {
|
||||
const map = useMap()
|
||||
const layerRef = useRef<GlLeafletLayer | null>(null)
|
||||
const styleRef = useRef(style)
|
||||
styleRef.current = style
|
||||
// State rather than a plain constant: the probe can say yes and the attach can
|
||||
// still fail once the page has spent its context budget, and that has to reach
|
||||
// the render or the raster tiles never appear.
|
||||
const [glUsable, setGlUsable] = useState(hasWebGL)
|
||||
|
||||
useEffect(() => {
|
||||
if (!glUsable) return
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
const maplibreGL = await loadLayerFactoryOrNull()
|
||||
if (cancelled) return
|
||||
if (!maplibreGL) {
|
||||
setGlUsable(false)
|
||||
return
|
||||
}
|
||||
|
||||
// The style is read through a ref, never from the dependency list: a style
|
||||
// change must retile in place rather than tear the layer down, the same
|
||||
@@ -64,68 +192,106 @@ export function VectorBasemap({ style }: { style: string }): null {
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
if (!attachGlLayer(map, layer, styleRef.current)) {
|
||||
setGlUsable(false)
|
||||
return
|
||||
}
|
||||
layerRef.current = layer
|
||||
layer.addTo(map)
|
||||
creditBasemap(map, styleRef.current)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
layerRef.current?.remove()
|
||||
detachBasemapLayer(layerRef.current)
|
||||
layerRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
}, [map, glUsable])
|
||||
|
||||
// Restyle in place. Swapping the layer would drop a WebGL context per theme
|
||||
// toggle, and browsers cap those in the low teens.
|
||||
useEffect(() => {
|
||||
layerRef.current?.getMaplibreMap()?.setStyle(style)
|
||||
restyleBasemap(layerRef.current, style)
|
||||
}, [style])
|
||||
|
||||
// No WebGL, no vector tiles. Raster keeps the map readable instead of leaving
|
||||
// markers, routes and clusters floating over grey; see mapDefaults for why this
|
||||
// particular source. The satellite toggle in MapView was always raster and is
|
||||
// unaffected either way.
|
||||
if (!glUsable) {
|
||||
return (
|
||||
<TileLayer
|
||||
key="webgl-fallback"
|
||||
url={RASTER_FALLBACK_TILE_URL}
|
||||
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
|
||||
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default VectorBasemap
|
||||
|
||||
/**
|
||||
* The same layer for the maps that build Leaflet imperatively rather than
|
||||
* The same basemap for the maps that build Leaflet imperatively rather than
|
||||
* through react-leaflet: the journey map and the atlas.
|
||||
*
|
||||
* Loading is async because maplibre-gl only ever arrives through a dynamic
|
||||
* import, so the caller passes a `cancelled` probe: a map torn down mid-load
|
||||
* must not get a layer attached to it afterwards.
|
||||
* Loading is async because maplibre-gl only ever arrives through a dynamic import,
|
||||
* so the caller passes a `cancelled` probe: a map torn down mid-load must not get
|
||||
* a layer attached to it afterwards. What lands in `ref` is either the GL layer or
|
||||
* the raster stand-in, which is why callers go through restyleBasemap() and
|
||||
* detachBasemapLayer() rather than reaching for getMaplibreMap() themselves.
|
||||
*/
|
||||
export async function attachVectorBasemap(
|
||||
map: L.Map,
|
||||
style: string,
|
||||
ref: { current: GlLeafletLayer | null },
|
||||
ref: { current: BasemapLayer | null },
|
||||
cancelled: () => boolean,
|
||||
opts: { hideLabels?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
// Same gate as the component: a browser without WebGL never pays for the
|
||||
// maplibre chunk and gets raster tiles rather than an empty map (#2288).
|
||||
if (!hasWebGL()) {
|
||||
if (!cancelled()) ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
|
||||
const maplibreGL = await loadLayerFactoryOrNull()
|
||||
if (cancelled()) return
|
||||
if (!maplibreGL) {
|
||||
ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
|
||||
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
|
||||
ref.current = layer
|
||||
layer.addTo(map)
|
||||
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
|
||||
// one of its own.
|
||||
creditBasemap(map, style)
|
||||
// one of its own; attachGlLayer adds it once the layer is actually on the map.
|
||||
if (!attachGlLayer(map, layer, style)) {
|
||||
ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
ref.current = layer
|
||||
if (opts.hideLabels) hideLabelLayers(layer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every label from a vector style.
|
||||
*
|
||||
* The atlas draws country names into its own fills, so a basemap that repeats
|
||||
* them underneath is noise. OpenFreeMap has no label-free style, but its labels
|
||||
* are all `symbol` layers, while borders and coastlines are `line` and `fill`,
|
||||
* so hiding that one type leaves the geography intact.
|
||||
* The atlas draws country names into its own fills, so a basemap that repeats them
|
||||
* underneath is noise. OpenFreeMap has no label-free style, but its labels are all
|
||||
* `symbol` layers, while borders and coastlines are `line` and `fill`, so hiding
|
||||
* that one type leaves the geography intact.
|
||||
*
|
||||
* Bound to `style.load` rather than run once: the event fires again after every
|
||||
* setStyle, so a theme switch keeps the labels off without a second call site.
|
||||
*/
|
||||
export function hideLabelLayers(layer: GlLeafletLayer): void {
|
||||
export function hideLabelLayers(layer: BasemapLayer): void {
|
||||
// Nothing to hide on the raster stand-in: OSM bakes its labels into the tile.
|
||||
// A WebGL-less atlas therefore shows country names twice, which is cosmetic and
|
||||
// the price of having a basemap at all, because the only keyless alternative is
|
||||
// none.
|
||||
if (!isGlLayer(layer)) return
|
||||
const gl = layer.getMaplibreMap()
|
||||
if (!gl) return
|
||||
const apply = () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns } from './dayRoutePlan'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ day_id: 1, order_index: order, place: buildPlace({ lat, lng }), ...extra })
|
||||
|
||||
const inputs = (over: Partial<{
|
||||
days: Day[]; assignments: AssignmentsMap; reservations: Reservation[]
|
||||
accommodations: Accommodation[]; optimizeFromAccommodation: boolean | undefined
|
||||
}> = {}) => ({
|
||||
days: [buildDay({ id: 1, day_number: 1 })],
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
accommodations: [],
|
||||
optimizeFromAccommodation: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('buildDayRouteRuns', () => {
|
||||
it('FE-MAP-DRP-001: orders a day\'s located stops into one run', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 1), at(45.76, 4.83, 0)] },
|
||||
}))
|
||||
|
||||
expect(runs).toHaveLength(1)
|
||||
// order_index, not the order they happen to sit in the array.
|
||||
expect(runs[0].map(p => p.lat)).toEqual([45.76, 48.86])
|
||||
expect(runs[0].every(p => p.isPlace)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-002: drops a stop with no coordinates', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: {
|
||||
'1': [
|
||||
at(48.86, 2.35, 0),
|
||||
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: null, lng: null }) }),
|
||||
at(45.76, 4.83, 2),
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(runs[0]).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-003: a lone stop is no drive at all', () => {
|
||||
expect(buildDayRouteRuns(1, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-004: carries the per-leg travel modes the router resolves against', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: {
|
||||
'1': [
|
||||
at(48.86, 2.35, 0, { leg_transport_mode: 'walking' }),
|
||||
at(48.88, 2.36, 1, { incoming_leg_transport_mode: 'cycling' }),
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(runs[0][0].leg_transport_mode).toBe('walking')
|
||||
expect(runs[0][1].incoming_leg_transport_mode).toBe('cycling')
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-005: two real places stay one run however far apart they are', () => {
|
||||
// Paris → Tokyo is far past MAX_DRIVE_KM, but both are real places someone
|
||||
// planned, so the run stands: the reachability guard only ever splits a leg
|
||||
// that touches a booking endpoint (#2133).
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(35.68, 139.69, 1)] },
|
||||
}))
|
||||
|
||||
expect(runs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-006: bookends the day with its accommodation when the setting is on', () => {
|
||||
const accommodation = {
|
||||
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
|
||||
start_day_id: 1, end_day_id: 1,
|
||||
} as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
|
||||
accommodations: [accommodation],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
|
||||
const flat = runs.flat()
|
||||
expect(flat.some(p => p.lat === 48.80 && !p.isPlace)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-007: leaves the hotel out when the setting is off', () => {
|
||||
const accommodation = {
|
||||
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
|
||||
start_day_id: 1, end_day_id: 1,
|
||||
} as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
|
||||
accommodations: [accommodation],
|
||||
optimizeFromAccommodation: false,
|
||||
}))
|
||||
|
||||
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { withHotelBookends } from './RouteCalculator'
|
||||
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
||||
|
||||
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
|
||||
export interface DayRoutePoint {
|
||||
lat: number
|
||||
lng: number
|
||||
isPlace: boolean
|
||||
leg_transport_mode?: string | null
|
||||
incoming_leg_transport_mode?: string | null
|
||||
}
|
||||
|
||||
/** Everything the plan depends on, passed in rather than read from the store, so the
|
||||
* builder stays pure and one caller can run it over every day of a trip at once. */
|
||||
export interface DayRouteInputs {
|
||||
days: Day[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
accommodations: Accommodation[]
|
||||
optimizeFromAccommodation: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The runs of waypoints a day's route is drawn from, hotel bookends folded in.
|
||||
*
|
||||
* Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the
|
||||
* same line the day view does. Every rule below is load-bearing and issue-numbered —
|
||||
* a second copy of it would drift the moment either surface got the next fix.
|
||||
*/
|
||||
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
|
||||
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
|
||||
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
return d ? (d.day_number ?? allDays.indexOf(d)) : null
|
||||
}
|
||||
const thisOrder = dayOrder(dayId)
|
||||
|
||||
// The order the day plan shows is the order the map has to draw, so take it from
|
||||
// the same place the plan does rather than rebuilding it here. getTransportForDay
|
||||
// brings the span filter, the hotel/assignment exclusions and the leg expansion with
|
||||
// it; getMergedItems places each leg by its own saved position, falling back to its
|
||||
// time. Notes carry no coordinates, so none are passed.
|
||||
const dayTransports = thisOrder == null ? [] : getTransportForDay({
|
||||
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
|
||||
dayId,
|
||||
dayAssignmentIds: da.map(a => a.id),
|
||||
days: allDays,
|
||||
})
|
||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
|
||||
const entries: Entry[] = merged.flatMap((item): Entry[] => {
|
||||
if (item.type === 'place') {
|
||||
const a = item.data
|
||||
if (!a.place?.lat || !a.place?.lng) return []
|
||||
return [{
|
||||
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
||||
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
|
||||
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
|
||||
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
|
||||
}]
|
||||
}
|
||||
if (item.type !== 'transport') return []
|
||||
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
|
||||
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
|
||||
})
|
||||
|
||||
// Group located places into driving runs. A transport WITH a location anchors the
|
||||
// route to its departure point then breaks the run (you don't drive the flight); its
|
||||
// arrival starts the next. A transport WITHOUT a location is ignored entirely. A run
|
||||
// is only a real drive when it contains at least one actual place, or two back-to-back
|
||||
// transports pair into a phantom [airport → airport] road route (#1394). A booking
|
||||
// endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival
|
||||
// sorted in among the day's local stops gets stapled to one of them (#2133).
|
||||
const runs: DayRoutePoint[][] = []
|
||||
let currentRun: DayRoutePoint[] = []
|
||||
let runHasPlace = false
|
||||
const closeRun = () => {
|
||||
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
|
||||
currentRun = []
|
||||
runHasPlace = false
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
// The open run may be nothing but a far-away arrival endpoint — break rather
|
||||
// than draw the ocean.
|
||||
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
|
||||
runHasPlace = true
|
||||
} else if (entry.from || entry.to) {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
|
||||
closeRun()
|
||||
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
|
||||
}
|
||||
}
|
||||
closeRun()
|
||||
|
||||
// Bookend the route with the day's accommodation, so the drawn line matches the
|
||||
// sidebar's hotel legs.
|
||||
const day = allDays.find(d => d.id === dayId)
|
||||
const bookends = day && optimizeFromAccommodation !== false
|
||||
? getDayBookendHotels(day, allDays, accommodations)
|
||||
: null
|
||||
const flatPts: DayRoutePoint[] = []
|
||||
for (const e of entries) {
|
||||
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
|
||||
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
|
||||
}
|
||||
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
||||
// falls through to the day default for hotel-adjacent legs unless the place endpoint
|
||||
// carries its own override.
|
||||
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
|
||||
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
|
||||
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
|
||||
// an arrival/departure day (#1321, #2133) all draw no bookend.
|
||||
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
|
||||
const firstStop = entries.find(contributes)
|
||||
const lastStop = [...entries].reverse().find(contributes)
|
||||
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
|
||||
if (!e) return undefined
|
||||
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
// check-out hotel its origin, which the no-time bookend default respects (#2157).
|
||||
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
|
||||
const firstWay = flatPts[0]
|
||||
const lastWay = flatPts[flatPts.length - 1]
|
||||
const morningHotel = hotelPt(bookends?.morning)
|
||||
const eveningHotel = hotelPt(bookends?.evening)
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
|
||||
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
|
||||
runs,
|
||||
firstWay,
|
||||
lastWay,
|
||||
drawMorning ? morningHotel : null,
|
||||
drawEvening ? eveningHotel : null,
|
||||
)
|
||||
|
||||
// Transfer day with no activities: you check out of one accommodation and into
|
||||
// another, so there are no waypoints for withHotelBookends to attach a leg to.
|
||||
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
|
||||
const m = hotelPt(bookends?.morning)
|
||||
const e = hotelPt(bookends?.evening)
|
||||
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
||||
}
|
||||
|
||||
return runsWithHotel
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The drop shadow every floating map control carries.
|
||||
*
|
||||
* Tighter and darker than `--sidebar-shadow`, which is built for a panel resting on
|
||||
* the app background: over map tiles its 0.10 alpha spread over 32px all but
|
||||
* disappears, so a round control read as painted onto the map rather than floating
|
||||
* above it. This is the locate button's shadow, which was always the odd one out for
|
||||
* exactly that reason — the others were the ones that needed to match it.
|
||||
*/
|
||||
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
|
||||
/**
|
||||
* jsdom has no drag implementation, so these drive the events the browser would
|
||||
@@ -82,3 +82,93 @@ describe('makeMarkerDraggable', () => {
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makePoiDraggable', () => {
|
||||
afterEach(() => {
|
||||
window.__dragData = null
|
||||
document.body.classList.remove('dragging-poi')
|
||||
})
|
||||
|
||||
const dragEvent = (type: string) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
|
||||
const store: Record<string, string> = {}
|
||||
Object.defineProperty(e, 'dataTransfer', {
|
||||
value: {
|
||||
setData: (k: string, v: string) => { store[k] = v },
|
||||
getData: (k: string) => store[k] ?? '',
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
},
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
expect(el.getAttribute('draggable')).toBe('true')
|
||||
|
||||
const e = dragEvent('dragstart')
|
||||
el.dispatchEvent(e)
|
||||
|
||||
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
|
||||
// Mirrored on window because dataTransfer is unreadable during dragover, and the
|
||||
// map has to know what is coming while it is still being hovered.
|
||||
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(true)
|
||||
|
||||
el.dispatchEvent(dragEvent('dragend'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(false)
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
const seen = vi.fn()
|
||||
document.body.appendChild(el)
|
||||
document.body.addEventListener('mousedown', seen)
|
||||
|
||||
el.dispatchEvent(down)
|
||||
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
document.body.removeEventListener('mousedown', seen)
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
|
||||
const el = document.createElement('div')
|
||||
const off = makePoiDraggable(el, 'node:9')
|
||||
off()
|
||||
|
||||
expect(el.hasAttribute('draggable')).toBe(false)
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const start = dragEvent('dragstart')
|
||||
el.dispatchEvent(start)
|
||||
|
||||
expect(draggedPoiId(start)).toBe('node:9')
|
||||
|
||||
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBe('node:9')
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,3 +48,51 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
|
||||
*
|
||||
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
|
||||
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
|
||||
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
|
||||
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
|
||||
* just as well once it is projected onto the routed geometry.
|
||||
*/
|
||||
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
|
||||
el.setAttribute('draggable', 'true')
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
e.dataTransfer?.setData('poiOsmId', osmId)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { poiOsmId: osmId }
|
||||
el.classList.add('marker-dragging')
|
||||
document.body.classList.add('dragging-poi')
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
window.__dragData = null
|
||||
el.classList.remove('marker-dragging')
|
||||
document.body.classList.remove('dragging-poi')
|
||||
}
|
||||
// Same reason as above: without this the map pans out from under the pointer as the
|
||||
// drag begins, and the route is impossible to aim at.
|
||||
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
|
||||
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
el.addEventListener('dragend', onDragEnd)
|
||||
el.addEventListener('mousedown', onMouseDown)
|
||||
|
||||
return () => {
|
||||
el.removeAttribute('draggable')
|
||||
el.removeEventListener('dragstart', onDragStart)
|
||||
el.removeEventListener('dragend', onDragEnd)
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/** The OSM id being dragged, when a corridor hit is in flight. */
|
||||
export function draggedPoiId(e: DragEvent): string | null {
|
||||
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
|
||||
if (fromTransfer) return fromTransfer
|
||||
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
|
||||
return window.__dragData?.poiOsmId ?? null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
|
||||
import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
|
||||
|
||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||
@@ -15,15 +19,27 @@ export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
||||
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
||||
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
||||
// The colour lives with the road-trip categories, because the corridor search offers
|
||||
// the same one and two definitions of a blue is how they drift apart.
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
|
||||
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||
]
|
||||
|
||||
/**
|
||||
* What a drive needs. Kept out of POI_CATEGORIES on purpose: these are not offered by
|
||||
* the explore pill, they are what the road trip corridor searches for. They are in the
|
||||
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
|
||||
* instead of the nameless grey dot an unknown category falls back to.
|
||||
*/
|
||||
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
|
||||
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
|
||||
)
|
||||
|
||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||
POI_CATEGORIES.map(c => [c.key, c]),
|
||||
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
@@ -34,10 +50,25 @@ export interface Poi {
|
||||
lng: number
|
||||
category: string
|
||||
poi_type: string
|
||||
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
|
||||
brand?: string | null
|
||||
brand_wikidata?: string | null
|
||||
address: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
opening_hours: string | null
|
||||
cuisine: string | null
|
||||
/**
|
||||
* What a charging station offers, when it is one and OSM says so.
|
||||
*
|
||||
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
|
||||
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
|
||||
* own answer here and must not be read as a no.
|
||||
*/
|
||||
charging?: {
|
||||
sockets: { type: string; count: number | null; kw: number | null }[]
|
||||
capacity: number | null
|
||||
fee: boolean | null
|
||||
} | null
|
||||
source: 'openstreetmap'
|
||||
}
|
||||
|
||||
@@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => {
|
||||
attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled)
|
||||
expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI'])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// 50 px between the ends, under the 80 px car floor; the road loops out 300 px.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const bare = freshMap()
|
||||
attach(bare).update([hop(7, 'car', 0.05)], opts)
|
||||
expect(features(bare)).toHaveLength(0)
|
||||
|
||||
const routed = freshMap()
|
||||
attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]]))
|
||||
expect(features(routed)).toHaveLength(1)
|
||||
expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat]))
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
const map = freshMap()
|
||||
attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]]))
|
||||
expect(features(map)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReservationMapboxOverlay waypoint edge cases', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -259,27 +260,31 @@ export class ReservationMapboxOverlay {
|
||||
})
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
private linesFor(item: TransportItem): [number, number][][] {
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
private render() {
|
||||
const map = this.map
|
||||
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
|
||||
|
||||
const show = this.opts.showConnections
|
||||
|
||||
// Visible filter: require the on-screen pixel distance between
|
||||
// endpoints to exceed a type-specific minimum, same as the Leaflet
|
||||
// overlay, so tiny no-op transport lines don't clutter the map.
|
||||
// Visible filter: a hop draws once what it will draw is long enough on
|
||||
// screen to be worth it, same as the Leaflet overlay, so tiny no-op
|
||||
// transport lines don't clutter the map. Measured along the drawn line,
|
||||
// not between the ends: a routed drive can cover half the screen while
|
||||
// its endpoints sit close together (#2275).
|
||||
const project = (p: readonly [number, number]) => map.project([p[1], p[0]])
|
||||
const visibleItems = show ? this.items.filter(item => {
|
||||
try {
|
||||
// A transit journey draws its real alignment, not a straight from->to line, so
|
||||
// don't let the endpoint-proximity declutter hide it when the map is zoomed out
|
||||
// (#1570). Mirrors the Leaflet overlay.
|
||||
if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true
|
||||
const fromPx = map.project([item.from.lng, item.from.lat])
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return dist >= minPx
|
||||
return hopIsVisible(item.type, this.linesFor(item), project)
|
||||
} catch { return true }
|
||||
}) : []
|
||||
|
||||
@@ -293,8 +298,7 @@ export class ReservationMapboxOverlay {
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (dist >= minPx) labelVisibleIds.add(item.res.id)
|
||||
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -320,9 +324,7 @@ export class ReservationMapboxOverlay {
|
||||
}))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map(seg => ({
|
||||
return this.linesFor(item).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { countRoute, flushRouteUsage } from './routeUsageCounter'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do".
|
||||
*
|
||||
* Two things are worth pinning: that it batches rather than posting per route, because a
|
||||
* counter that costs a request of its own measures the load by adding to it, and that it
|
||||
* never throws — it sits on the hot path of every route the map draws.
|
||||
*/
|
||||
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: false,
|
||||
waypoints: 3,
|
||||
km: 100,
|
||||
failed: false,
|
||||
}
|
||||
|
||||
describe('routeUsageCounter', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
flushRouteUsage()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => {
|
||||
countRoute(sample)
|
||||
expect(routeUsageApi.report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => {
|
||||
countRoute(sample)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }])
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, waypoints: 5, km: 200 })
|
||||
countRoute({ ...sample, failed: true })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 })
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, surface: 'alternatives' })
|
||||
countRoute({ ...sample, selfHosted: true })
|
||||
countRoute({ ...sample, profile: 'walking' })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => {
|
||||
for (let i = 0; i < 25; i++) countRoute(sample)
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries[0].requests).toBe(25)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => {
|
||||
vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline'))
|
||||
countRoute(sample)
|
||||
expect(() => flushRouteUsage()).not.toThrow()
|
||||
await Promise.resolve()
|
||||
|
||||
countRoute(sample)
|
||||
flushRouteUsage()
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1]
|
||||
expect(entries[0].requests).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* Counts the routing this browser asks for, and posts the totals in batches.
|
||||
*
|
||||
* All routing happens client-side against an external engine, so the server never
|
||||
* sees a route request and cannot count one. Without a count nobody knows what a
|
||||
* self-hosted router would have to carry — how many requests a real trip costs, how
|
||||
* big they are, how much already goes to an operator's own engine.
|
||||
*
|
||||
* Counters only. What goes over the wire is four integers per bucket: how many
|
||||
* requests, how many waypoints, how many kilometres, how many failed. No coordinate,
|
||||
* no route, no trip, nothing that says where anybody went.
|
||||
*
|
||||
* Batched on purpose. A counter that cost a request of its own would measure the load
|
||||
* by adding to it, so the tally is held in memory and flushed on a timer, when it
|
||||
* grows past a threshold, and when the page goes away.
|
||||
*/
|
||||
|
||||
/** Flush when the tally has this many requests in it, whichever comes first. */
|
||||
const FLUSH_AFTER_REQUESTS = 25
|
||||
/** …or after this long, so a quiet session still reports what it did. */
|
||||
const FLUSH_AFTER_MS = 60_000
|
||||
/** Guard against a pathological session: drop the tally rather than grow forever. */
|
||||
const MAX_BUCKETS = 40
|
||||
|
||||
type Bucket = Omit<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
let pending = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let unloadWired = false
|
||||
|
||||
function flush(): void {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null }
|
||||
if (!buckets.size) return
|
||||
const entries = [...buckets.values()]
|
||||
buckets.clear()
|
||||
pending = 0
|
||||
// Fire and forget: a failed report is a lost count, not a problem worth telling
|
||||
// anybody about. The next flush carries on from an empty tally either way.
|
||||
void routeUsageApi.report(entries).catch(() => {})
|
||||
}
|
||||
|
||||
function wireUnload(): void {
|
||||
if (unloadWired || typeof document === 'undefined') return
|
||||
unloadWired = true
|
||||
// `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile,
|
||||
// where a session ends by switching apps rather than by closing a tab.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one routing request. Never throws and never awaits: it sits on the hot path
|
||||
* of every route the map draws.
|
||||
*/
|
||||
export function countRoute(sample: {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
waypoints: number
|
||||
km: number
|
||||
failed: boolean
|
||||
}): void {
|
||||
try {
|
||||
wireUnload()
|
||||
const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}`
|
||||
const bucket = buckets.get(key)
|
||||
if (bucket) {
|
||||
bucket.requests += 1
|
||||
bucket.waypoints += sample.waypoints
|
||||
bucket.km += sample.km
|
||||
if (sample.failed) bucket.failed += 1
|
||||
} else {
|
||||
if (buckets.size >= MAX_BUCKETS) return
|
||||
buckets.set(key, {
|
||||
profile: sample.profile,
|
||||
surface: sample.surface,
|
||||
selfHosted: sample.selfHosted,
|
||||
requests: 1,
|
||||
waypoints: sample.waypoints,
|
||||
km: sample.km,
|
||||
failed: sample.failed ? 1 : 0,
|
||||
})
|
||||
}
|
||||
pending += 1
|
||||
if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return }
|
||||
if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS)
|
||||
} catch { /* counting must never break routing */ }
|
||||
}
|
||||
|
||||
/** Flushes now. Exported for tests and for a deliberate "report before you go". */
|
||||
export function flushRouteUsage(): void {
|
||||
flush()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useMergedMapPois } from './useMergedMapPois'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
|
||||
*
|
||||
* The corridor answers "what is along the drive", the category pill answers "what is in
|
||||
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
|
||||
* only new when the content really is — the map rebuilds every pin off it.
|
||||
*/
|
||||
|
||||
const poi = (osm_id: string, name = osm_id): Poi => ({
|
||||
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
|
||||
} as Poi)
|
||||
|
||||
describe('useMergedMapPois', () => {
|
||||
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
|
||||
const explore = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(null, explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, []))
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
|
||||
const explore = [poi('n2')]
|
||||
const { result } = renderHook(() => useMergedMapPois([], explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
|
||||
// The corridor copy carries how far along the drive it sits, which the pill's does not.
|
||||
const corridor = [poi('n1', 'Hotel am Weg')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
|
||||
expect(result.current).toBe(corridor)
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].name).toBe('Hotel am Weg')
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const explore = [poi('n2')]
|
||||
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
|
||||
const first = result.current
|
||||
rerender()
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMergedMapPois — the refuel offers', () => {
|
||||
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
|
||||
|
||||
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
|
||||
// In road trip mode this is the only channel a POI reaches the map through, so
|
||||
// without it somebody is asked to accept a stop they cannot see.
|
||||
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('a')]
|
||||
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
|
||||
initialProps: { o: [] as Poi[] },
|
||||
})
|
||||
const first = result.current
|
||||
rerender({ o: [] })
|
||||
expect(result.current).toBe(first)
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* The findings the map should draw, when two searches can be running at once.
|
||||
*
|
||||
* In road trip mode the corridor search answers "what is along this drive". The category
|
||||
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
|
||||
* stop is exactly the case where someone wants the second one without giving up the first,
|
||||
* so the two lists are drawn together rather than one replacing the other.
|
||||
*
|
||||
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
|
||||
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
|
||||
* copy wins because it carries how far along the drive it sits.
|
||||
*
|
||||
* `offered` is the third case: the handful of filling stations proposed where a tank runs
|
||||
* out. They belong on the map for a plain reason — somebody is being asked to accept a
|
||||
* stop, and a stop you cannot see is not one you can judge — but they are not a search
|
||||
* result and must not survive the panel being closed, which is why they arrive separately
|
||||
* rather than being folded into the corridor's own list.
|
||||
*
|
||||
* Returns the input array itself whenever there is nothing to merge. The map redraws off
|
||||
* this reference, so handing it a fresh array on every render would rebuild every pin.
|
||||
*/
|
||||
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
|
||||
return useMemo(() => {
|
||||
const base = corridor === null ? explore : merge(corridor, explore)
|
||||
return merge(base, offered)
|
||||
}, [corridor, explore, offered])
|
||||
}
|
||||
|
||||
/** Nothing offered, as one stable array, so the default never changes identity. */
|
||||
const NONE: Poi[] = []
|
||||
|
||||
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
|
||||
function merge(a: Poi[], b: Poi[]): Poi[] {
|
||||
if (!b.length) return a
|
||||
if (!a.length) return b
|
||||
const seen = new Set(a.map(p => p.osm_id))
|
||||
const extra = b.filter(p => !seen.has(p.osm_id))
|
||||
return extra.length ? [...a, ...extra] : a
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import { useTripRouteOverview } from './useTripRouteOverview'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types'
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>()
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() }
|
||||
})
|
||||
|
||||
const { calculateRouteWithLegs } = await import('./RouteCalculator')
|
||||
|
||||
const leg = (distance: number, duration: number): RouteSegment => ({
|
||||
mid: [0, 0], from: [0, 0], to: [0, 0],
|
||||
distance, duration,
|
||||
distanceText: `${Math.round(distance / 1000)} km`,
|
||||
durationText: '1 h', walkingText: '5 h', drivingText: '1 h',
|
||||
})
|
||||
|
||||
const at = (lat: number, lng: number, order: number, dayId: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }), ...extra })
|
||||
|
||||
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
|
||||
const ASSIGNMENTS: AssignmentsMap = {
|
||||
'1': [at(48.86, 2.35, 0, 1), at(48.88, 2.36, 1, 1)],
|
||||
'2': [at(45.76, 4.83, 0, 2), at(45.78, 4.85, 1, 2)],
|
||||
}
|
||||
|
||||
const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days = DAYS) =>
|
||||
renderHook(
|
||||
({ assignments: a }: { assignments: AssignmentsMap }) =>
|
||||
useTripRouteOverview(7, days, a, [], [], 'driving', enabled),
|
||||
{ initialProps: { assignments } },
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(calculateRouteWithLegs).mockReset()
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
|
||||
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
|
||||
distance: 12000, duration: 900,
|
||||
legs: [leg(12000, 900)],
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripRouteOverview', () => {
|
||||
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
|
||||
const { result } = render(false)
|
||||
|
||||
expect(result.current.days).toEqual([])
|
||||
expect(result.current.totalDistance).toBe(0)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-002: sums every day into the trip total', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days).toHaveLength(2)
|
||||
expect(result.current.days.map(d => d.distance)).toEqual([12000, 12000])
|
||||
expect(result.current.totalDistance).toBe(24000)
|
||||
expect(result.current.totalDuration).toBe(1800)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const colors = result.current.days.map(d => d.color)
|
||||
// The same palette either way, so a day does not change colour when the trip is
|
||||
// read as a road trip instead of an overview.
|
||||
expect(colors[0]).toEqual(dayColor(1))
|
||||
expect(colors[1]).toEqual(dayColor(2))
|
||||
// Every drawn line is labelled with the colour of the day it belongs to.
|
||||
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
|
||||
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
|
||||
const { result } = render(true, {
|
||||
'1': [at(48.86, 2.35, 0, 1, { leg_transport_mode: 'walking' }), at(48.88, 2.36, 1, 1)],
|
||||
'2': ASSIGNMENTS['2'],
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].modes).toEqual(['walking'])
|
||||
expect(result.current.days[1].modes).toEqual(['driving'])
|
||||
expect(result.current.days[0].segments[0].mode).toBe('walking')
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-005: draws straight lines first and keeps them when routing refuses', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429'))
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
// The shape of the trip is still on the map...
|
||||
expect(result.current.lines).toHaveLength(2)
|
||||
expect(result.current.lines[0]).toEqual([[48.86, 2.35], [48.88, 2.36]])
|
||||
// ...but a leg that never answered contributes no distance.
|
||||
expect(result.current.totalDistance).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-006: a day with nothing to drive is left out', async () => {
|
||||
const { result } = render(true, { '1': ASSIGNMENTS['1'], '2': [at(45.76, 4.83, 0, 2)] })
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days.map(d => d.dayId)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-007: frames the whole trip, not one day of it', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.focusPoints.length).toBe(result.current.lines.flat().length)
|
||||
expect(result.current.focusPoints.length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-008: renaming a place does not re-route the trip', async () => {
|
||||
const { result, rerender } = render()
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const calls = vi.mocked(calculateRouteWithLegs).mock.calls.length
|
||||
|
||||
expect(calls).toBeGreaterThan(0)
|
||||
|
||||
// A new object identity with the same geometry: the plan memo re-runs, the
|
||||
// routing round must not.
|
||||
rerender({
|
||||
assignments: {
|
||||
...ASSIGNMENTS,
|
||||
'1': ASSIGNMENTS['1'].map(a => ({ ...a, place: { ...a.place, name: 'Renamed' } })),
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
/** One travel day of the overview: the roads it covers, in its own colour. */
|
||||
export interface TripOverviewDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** The core and casing this day is drawn in — the road trip's palette, so a day is
|
||||
* the same colour whichever way the trip is being read. */
|
||||
color: { line: string; casing: string }
|
||||
/** One polyline per run of the day, `[lat, lng]`. */
|
||||
lines: [number, number][][]
|
||||
segments: RouteSegment[]
|
||||
/** Metres and seconds, summed over the day's legs. */
|
||||
distance: number
|
||||
duration: number
|
||||
/** The modes this day is actually travelled in, first use first. */
|
||||
modes: string[]
|
||||
}
|
||||
|
||||
export interface TripRouteOverview {
|
||||
days: TripOverviewDay[]
|
||||
/** Every day's polylines flattened in trip order — what the map draws. */
|
||||
lines: [number, number][][]
|
||||
/** The colour of each entry of `lines`, same index. */
|
||||
lineColors: { line: string; casing: string }[]
|
||||
segments: RouteSegment[]
|
||||
/** Every drawn coordinate, so the map can frame the whole trip at once. */
|
||||
focusPoints: [number, number][]
|
||||
totalDistance: number
|
||||
totalDuration: number
|
||||
/** True until every leg has answered — the totals are a partial sum until then. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const EMPTY: TripRouteOverview = {
|
||||
days: [], lines: [], lineColors: [], segments: [], focusPoints: [],
|
||||
totalDistance: 0, totalDuration: 0, loading: false,
|
||||
}
|
||||
|
||||
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
|
||||
* exactly as the single-day route builds them. */
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string }
|
||||
|
||||
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
let i = 0
|
||||
while (i < run.length - 1) {
|
||||
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
|
||||
let end = i + 1
|
||||
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
|
||||
chunks.push({ points: run.slice(i, end + 1), mode })
|
||||
i = end
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
|
||||
|
||||
/**
|
||||
* Every travel day's route at once, each day in its own colour, with the trip's total
|
||||
* distance (#1736).
|
||||
*
|
||||
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
|
||||
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
|
||||
* you are looking at it alone or at the whole trip. Only the aggregation is new.
|
||||
*
|
||||
* Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and
|
||||
* the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the
|
||||
* day route and the sidebar connectors, so the day already on screen costs nothing.
|
||||
*/
|
||||
export function useTripRouteOverview(
|
||||
tripId: number | null,
|
||||
days: Day[],
|
||||
assignments: AssignmentsMap,
|
||||
reservations: Reservation[],
|
||||
accommodations: Accommodation[],
|
||||
profile: RouteProfileKey,
|
||||
enabled: boolean,
|
||||
): TripRouteOverview {
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const plan = useMemo(() => {
|
||||
if (!enabled) return []
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(day => ({
|
||||
day,
|
||||
runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }),
|
||||
}))
|
||||
.filter(entry => entry.runs.length > 0)
|
||||
}, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation])
|
||||
|
||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||
// place or editing its notes must not fire a routing round.
|
||||
const planKey = useMemo(
|
||||
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
|
||||
.map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|'))
|
||||
.join('/')}`).join(';'),
|
||||
[plan],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort()
|
||||
if (!plan.length) { setResult(EMPTY); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
|
||||
|
||||
// Chunk every day up front, so the routed answers can be filed by position and
|
||||
// reassembled in order however they come back.
|
||||
const chunked = plan.map(({ day, runs }) => {
|
||||
const dayDefaultMode = day.default_transport_mode || profile
|
||||
return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) }
|
||||
})
|
||||
|
||||
const assemble = (
|
||||
routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][],
|
||||
): TripRouteOverview => {
|
||||
const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => {
|
||||
const lines: [number, number][][] = []
|
||||
const segments: RouteSegment[] = []
|
||||
runs.forEach((chunks, r) => {
|
||||
const polyline: [number, number][] = []
|
||||
chunks.forEach((chunk, c) => {
|
||||
const answer = routed[d][r][c]
|
||||
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
|
||||
for (const point of coords) {
|
||||
// Drop the point shared with the previous chunk so concatenated legs
|
||||
// don't leave a duplicate at each junction.
|
||||
const last = polyline[polyline.length - 1]
|
||||
if (last && last[0] === point[0] && last[1] === point[1]) continue
|
||||
polyline.push(point)
|
||||
}
|
||||
if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode })
|
||||
})
|
||||
if (polyline.length >= 2) lines.push(polyline)
|
||||
})
|
||||
const modes: string[] = []
|
||||
for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode)
|
||||
return {
|
||||
dayId: day.id,
|
||||
dayNumber: day.day_number ?? 0,
|
||||
date: day.date ?? null,
|
||||
title: day.title ?? null,
|
||||
color: colorFor(day),
|
||||
lines,
|
||||
segments,
|
||||
distance: segments.reduce((sum, s) => sum + s.distance, 0),
|
||||
duration: segments.reduce((sum, s) => sum + s.duration, 0),
|
||||
modes,
|
||||
}
|
||||
})
|
||||
const lines = overviewDays.flatMap(d => d.lines)
|
||||
return {
|
||||
days: overviewDays,
|
||||
lines,
|
||||
lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)),
|
||||
segments: overviewDays.flatMap(d => d.segments),
|
||||
focusPoints: lines.flat(),
|
||||
totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0),
|
||||
totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0),
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as
|
||||
({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)))
|
||||
|
||||
// Straight lines first so the shape of the trip is on screen immediately, then the
|
||||
// real roads replace them — the same two-step the single-day route draws with.
|
||||
setResult({ ...assemble(routed), loading: true })
|
||||
|
||||
;(async () => {
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
chunked.forEach(({ day, runs }, d) => {
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
|
||||
} catch {
|
||||
// Routing refused this leg (usually a rate limit) — it keeps its
|
||||
// straight line and contributes no distance, exactly as a day route's
|
||||
// failed leg does.
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Small pool on purpose, the same one the sidebar connectors use: a fortnight is
|
||||
// dozens of legs and the routing host is often a shared OSRM.
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < tasks.length && !controller.signal.aborted) await tasks[next++]()
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
|
||||
|
||||
if (!controller.signal.aborted) setResult(assemble(routed))
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
// planKey is derived from the same inputs as plan, so keying on the string is
|
||||
// equivalent while staying stable across unrelated renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planKey, profile, tripId, distanceUnit])
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
/**
|
||||
* A real polyline6, encoded from three known points and verified by decoding it back.
|
||||
* Precision matters here more than anywhere else in the file: read as polyline5 the
|
||||
* first vertex would be 535.511, which is not a latitude, and a wrong guess draws a
|
||||
* line into the sea rather than failing.
|
||||
*/
|
||||
const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE'
|
||||
const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]]
|
||||
|
||||
const answer = (summary: Record<string, unknown> = {}) => ({
|
||||
trip: {
|
||||
legs: [{ shape: SHAPE }],
|
||||
summary: {
|
||||
has_time_restrictions: false,
|
||||
has_toll: false,
|
||||
has_highway: true,
|
||||
has_ferry: false,
|
||||
// Asked in kilometres, so this is 295.7 km and 191 minutes — the real
|
||||
// Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds.
|
||||
length: 295.7,
|
||||
time: 11460,
|
||||
...summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const setSettings = (patch: Record<string, string>) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
|
||||
|
||||
afterEach(() => {
|
||||
setSettings({ routing_base_url: '', valhalla_base_url: '' })
|
||||
})
|
||||
|
||||
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
|
||||
const BERLIN = { lat: 52.52, lng: 13.405 }
|
||||
|
||||
describe('valhallaBase', () => {
|
||||
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
expect(valhallaAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
|
||||
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
|
||||
expect(valhallaBase()).toBe('https://valhalla.example.org')
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => {
|
||||
// An operator who filled in their own router chose against the public hosts, usually
|
||||
// because the instance is not supposed to reach third parties at all. Adding one they
|
||||
// never asked for would undo that, and their MLD-built OSRM answers exclude itself.
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
expect(valhallaBase()).toBeNull()
|
||||
expect(valhallaAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => {
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' })
|
||||
expect(valhallaBase()).toBe('https://v.example.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runFrom', () => {
|
||||
const total = (data: unknown) => runFrom(data)?.total ?? null
|
||||
|
||||
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
|
||||
const leg = total(answer())
|
||||
expect(leg).not.toBeNull()
|
||||
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg!.duration).toBe(11460)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
|
||||
const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false }))
|
||||
expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => {
|
||||
// The public instance omits a flag it has nothing to say about, and the caller turns
|
||||
// this into a label. Undefined has to become false, not a truthy object.
|
||||
const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } })
|
||||
expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => {
|
||||
// What a request with a via really answers: one summary per pair of stops. The
|
||||
// schedule is built from those, so flattening them into a total would lose the
|
||||
// arrival time at every stop but the last.
|
||||
const chain = {
|
||||
trip: {
|
||||
legs: [
|
||||
{ shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } },
|
||||
{ shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } },
|
||||
],
|
||||
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
|
||||
},
|
||||
}
|
||||
const run = runFrom(chain)!
|
||||
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
|
||||
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
|
||||
expect(run.total.distance).toBe(150000)
|
||||
expect(run.total.duration).toBe(5400)
|
||||
// One tolled leg makes the drive tolled. `every` here would call a day with a
|
||||
// single toll booth in it toll-free.
|
||||
expect(run.total.hasToll).toBe(true)
|
||||
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
|
||||
// There is no honest way to divide one total between them, and a guess would reach
|
||||
// the user as a wrong arrival time rather than as a missing one.
|
||||
const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } }
|
||||
expect(runFrom(two)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-005: an error body is not a leg', () => {
|
||||
// What a 400 carries: no trip at all. Parsing it as one would produce a route of
|
||||
// NaN kilometres rather than the null every caller reads as "no second way".
|
||||
expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
|
||||
expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull()
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull()
|
||||
expect(total(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => {
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legAvoids', () => {
|
||||
const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({
|
||||
coordinates: SHAPE_POINTS, distance: 1, duration: 1,
|
||||
hasToll: false, hasHighway: false, hasFerry: false, ...flags,
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-001: reads the answer, not the request', () => {
|
||||
// use_tolls: 0 is a weighting. Between two points with no untolled connection the
|
||||
// answer is still a tolled road, honestly flagged, and labelling that "No tolls"
|
||||
// would put a lie on the map.
|
||||
expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-002: each class reads its own flag', () => {
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false)
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false)
|
||||
expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRouteAvoiding', () => {
|
||||
it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(body).toMatchObject({
|
||||
costing: 'auto',
|
||||
costing_options: { auto: { use_tolls: 0 } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
})
|
||||
// The radius is what makes a place in a pedestrian zone routable at all: without it
|
||||
// Vienna's Stephansdom answers error 442 and the whole leg is lost.
|
||||
expect(body!.locations).toEqual([
|
||||
{ lat: 53.5511, lon: 9.9937, radius: 50 },
|
||||
{ lat: 52.52, lon: 13.405, radius: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-002: each class maps to its own costing option', async () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
seen.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
|
||||
expect(seen.map(b => b.costing_options)).toEqual([
|
||||
{ auto: { use_highways: 0 } },
|
||||
{ auto: { use_ferry: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing: string }
|
||||
seen.push(body.costing)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')
|
||||
expect(seen).toEqual(['pedestrian', 'bicycle'])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => {
|
||||
// Every failure means the same thing to the caller — there is no second way to
|
||||
// show — so a 400 for an unroutable point and a 429 for the rate limit both end
|
||||
// here rather than as an error over a map that is drawing fine.
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, () =>
|
||||
HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })
|
||||
)
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => {
|
||||
let calls = 0
|
||||
setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' })
|
||||
server.use(
|
||||
http.post('https://not-valhalla.example.org/route', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
// A 404 is about the URL rather than about this leg, so remembering it saves every
|
||||
// later leg a request against a host that will never answer.
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => {
|
||||
let calls = 0
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) }))
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRun over a whole day', () => {
|
||||
/** A chain answer with one summary per leg, which is what a real request returns. */
|
||||
const chain = (legCount: number) => ({
|
||||
trip: {
|
||||
legs: Array.from({ length: legCount }, () => ({
|
||||
shape: SHAPE,
|
||||
summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false },
|
||||
})),
|
||||
summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const stops = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
|
||||
|
||||
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
calls++
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(6), 'driving', [])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(run!.legs).toHaveLength(5)
|
||||
expect(run!.total.distance).toBe(50000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
|
||||
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
|
||||
// point, which is exact rather than approximate — measured over a six-stop day,
|
||||
// [0..3] plus [3..5] came back within two metres of the same day asked whole.
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
// Fourteen stops: the first run takes ten, the second starts on the tenth again and
|
||||
// takes the remaining five. Nine pairs plus four.
|
||||
expect(sizes).toEqual([10, 5])
|
||||
// Thirteen pairs across two requests, none lost and none counted twice.
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
expect(run!.total.distance).toBe(130000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await valhallaRun(stops(28), 'driving', [])
|
||||
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(10)
|
||||
expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => {
|
||||
// Half a day of legs is worse than none. The schedule chains leg times blindly and
|
||||
// cannot tell that some are missing, so it would print a complete, plausible,
|
||||
// silently wrong timetable instead of leaving a visible gap.
|
||||
//
|
||||
// The SECOND CHUNK fails, not the second request: one attempt is retried, so failing
|
||||
// by call number would just be answered on the retry.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
if (body.locations.length < 10) {
|
||||
return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 })
|
||||
}
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => {
|
||||
// The caller retries the whole task, so without this a rate limit on the last piece
|
||||
// of a split day throws away the pieces that already answered and asks for all of
|
||||
// them again — three times over, against a host that allows one request a second.
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
calls++
|
||||
if (calls === 2) return new HttpResponse(null, { status: 429 })
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
// First piece, second piece refused, second piece again.
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
|
||||
await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => {
|
||||
// A day whose legs do not match its stops cannot be assigned to the rail, and
|
||||
// guessing which stop is missing would put the wrong time against the right place.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2))))
|
||||
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { decodePolyline } from './transitGeometry'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* Valhalla, the second routing engine, asked only the questions OSRM cannot answer.
|
||||
*
|
||||
* The shipped OSRM hosts are built without excludable road classes: their car profile
|
||||
* says so in its own Lua ("no memory for classes on demand server"), and `exclude=`
|
||||
* comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking
|
||||
* for — drive this without the tolls — has never worked on a default install.
|
||||
*
|
||||
* FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there
|
||||
* avoidance is a runtime weighting rather than a profile that has to be rebuilt. This
|
||||
* module is the adapter: same question, different engine, answer shaped like the OSRM
|
||||
* one so the callers do not have to care which engine spoke.
|
||||
*
|
||||
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
|
||||
* - it caps at ten locations, and following a track sends a day's stops plus up to
|
||||
* nine anchors, so the bundled requests would start failing;
|
||||
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
|
||||
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
|
||||
* until a search radius is allowed;
|
||||
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
|
||||
* above OSRM's on average and 36 % above on one, and those times feed the arrival
|
||||
* clocks of a road trip day. Mixing engines inside one chain would move times
|
||||
* nobody asked to move.
|
||||
*
|
||||
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
|
||||
* on demand, drawn as an offer next to the route rather than replacing it.
|
||||
*/
|
||||
|
||||
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
|
||||
|
||||
/**
|
||||
* How far Valhalla may look for a road under a point, in metres.
|
||||
*
|
||||
* Zero is its default and it is the wrong default for TREK's data, which is full of
|
||||
* museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on
|
||||
* Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it
|
||||
* routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it.
|
||||
*/
|
||||
const SNAP_RADIUS_M = 50
|
||||
|
||||
/** TREK's profile names against Valhalla's costing models. */
|
||||
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'auto',
|
||||
walking: 'pedestrian',
|
||||
cycling: 'bicycle',
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller can ask to be left out, against the costing option that asks for it.
|
||||
*
|
||||
* Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason
|
||||
* this engine is worth having. Asked to avoid tolls between New York and Boston it
|
||||
* still returns a tolled road, because there is no untolled way, where OSRM's `exclude`
|
||||
* would refuse to answer at all. The caller has to read the answer to find out what it
|
||||
* got, which is what `hasToll` and friends are for.
|
||||
*/
|
||||
const AVOID_OPTION: Record<AvoidClass, string> = {
|
||||
motorway: 'use_highways',
|
||||
toll: 'use_tolls',
|
||||
ferry: 'use_ferry',
|
||||
}
|
||||
|
||||
/** The same three classes the rest of the app names; aliased so this file reads on its own. */
|
||||
export type AvoidClass = RouteAvoidClass
|
||||
|
||||
/**
|
||||
* Locations one request may carry. The eleventh answers
|
||||
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
|
||||
*/
|
||||
const MAX_LOCATIONS = 10
|
||||
|
||||
/**
|
||||
* Pause between the pieces of a split day, in milliseconds.
|
||||
*
|
||||
* The public instance allows about one request a second per client, and a day long
|
||||
* enough to be split is exactly the case that would fire two back to back. Matches the
|
||||
* 1100 ms the road trip rail already leaves between its own days.
|
||||
*/
|
||||
const CHUNK_PAUSE_MS = 1100
|
||||
|
||||
/**
|
||||
* Extra attempts for one piece of a split day.
|
||||
*
|
||||
* One, not a ladder: the common failure here is the public instance's rate limit, which
|
||||
* a single pause clears, and anything a second attempt cannot fix is not going to be
|
||||
* fixed by a fourth while somebody waits for their map.
|
||||
*/
|
||||
const CHUNK_RETRIES = 1
|
||||
|
||||
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
|
||||
export interface ValhallaLeg {
|
||||
coordinates: [number, number][]
|
||||
/** Metres. Valhalla is asked in kilometres and converted here. */
|
||||
distance: number
|
||||
/** Seconds. */
|
||||
duration: number
|
||||
/** What the route ACTUALLY has, as opposed to what was asked for. */
|
||||
hasToll: boolean
|
||||
hasHighway: boolean
|
||||
hasFerry: boolean
|
||||
}
|
||||
|
||||
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
|
||||
export interface ValhallaRun {
|
||||
legs: ValhallaLeg[]
|
||||
total: ValhallaLeg
|
||||
/**
|
||||
* Where each waypoint actually met the road.
|
||||
*
|
||||
* Reconstructed from the shape rather than read from a field, because Valhalla echoes
|
||||
* the coordinates it was GIVEN in `trip.locations` and never says where it bound them.
|
||||
* The ends of a leg's line are those points: measured against Füssen, its first vertex
|
||||
* came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same
|
||||
* request to the digit. Without this the dashed kerb-to-door spur from #2273 would
|
||||
* quietly disappear from every day routed by this engine.
|
||||
*/
|
||||
snapped: SnappedWaypoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance's Valhalla, or null when it should not ask one.
|
||||
*
|
||||
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
|
||||
* with one condition. An operator who filled in their OWN router chose deliberately
|
||||
* against the public hosts, usually because their instance is not supposed to talk to
|
||||
* third parties at all, and quietly adding a second public host they never asked for
|
||||
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
|
||||
* pipeline answers `exclude` on its own, which is exactly the fallback below.
|
||||
*/
|
||||
export function valhallaBase(): string | null {
|
||||
const settings = useSettingsStore.getState().settings
|
||||
const configured = settings.valhalla_base_url?.trim()
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
|
||||
}
|
||||
|
||||
/** True when this engine can be asked at all, so callers can skip building a request. */
|
||||
export function valhallaAvailable(): boolean {
|
||||
return valhallaBase() !== null
|
||||
}
|
||||
|
||||
/** Hosts that have already proved they are not a Valhalla, by base URL. */
|
||||
const notValhalla = new Set<string>()
|
||||
|
||||
/**
|
||||
* One leg with a road class weighted away, or null when it cannot be had.
|
||||
*
|
||||
* Null for every failure, deliberately: this is an offer next to a route that already
|
||||
* exists, so a host that is down, a rate limit, a point in a field and a genuinely
|
||||
* connected-only-by-motorway leg all mean the same thing to the caller — there is no
|
||||
* second way to show. The one failure worth remembering is a host that is not a
|
||||
* Valhalla at all, because that is true of every leg from now on rather than this one.
|
||||
*/
|
||||
export async function valhallaRouteAvoiding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaLeg | null> {
|
||||
const run = await valhallaRun([from, to], profile, [avoid], signal)
|
||||
return run ? run.total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain of waypoints, one leg per pair, or null when it cannot be had.
|
||||
*
|
||||
* Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled
|
||||
* into one request precisely so it appears at once rather than trickling in — so a day
|
||||
* over the cap is cut into runs that overlap by one point and the legs are concatenated.
|
||||
* That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5]
|
||||
* came back within two metres and under a second of the same day asked whole, because
|
||||
* the shared point is routed to and from identically.
|
||||
*
|
||||
* The pieces go out one after another with a pause, since a day long enough to need
|
||||
* splitting is exactly the one that would otherwise fire two requests inside a second
|
||||
* at a host that allows one.
|
||||
*/
|
||||
export async function valhallaRun(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
const chunks = chunkWaypoints(waypoints)
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (signal?.aborted) return null
|
||||
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
|
||||
// Retried per piece rather than per day. The caller retries the whole task, so a
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
let run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
// Not when the host just proved it is not a Valhalla at all: that answer will not
|
||||
// change on a second ask, and asking anyway is the request the memo exists to save.
|
||||
if (notValhalla.has(base)) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
}
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
if (!run) return null
|
||||
legs.push(...run.legs)
|
||||
}
|
||||
if (legs.length !== waypoints.length - 1) return null
|
||||
return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each waypoint met the road, taken off the ends of the legs that touch it.
|
||||
*
|
||||
* A leg's line starts at the bound position of its first stop and ends at the bound
|
||||
* position of its second, so stop i is the head of leg i, and the last stop is the tail
|
||||
* of the last leg. The distance is measured here rather than reported, the same recovery
|
||||
* `readSnapped` already applies when OSRM leaves the field out.
|
||||
*/
|
||||
function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] {
|
||||
return waypoints.map((waypoint, i) => {
|
||||
const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates
|
||||
const at = i < legs.length ? line[0] : line[line.length - 1]
|
||||
const asked: [number, number] = [waypoint.lat, waypoint.lng]
|
||||
return {
|
||||
asked,
|
||||
at,
|
||||
meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs of at most ten points, each starting on the one the previous ended at. */
|
||||
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
|
||||
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
|
||||
const chunks: Waypoint[][] = []
|
||||
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
|
||||
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = {}
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
const body = {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
costing,
|
||||
costing_options: { [costing]: options },
|
||||
directions_options: { units: 'kilometers' },
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/route`, {
|
||||
method: 'POST',
|
||||
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
|
||||
// client to identify itself with. It costs a preflight, which is answered.
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
|
||||
// or a proxy that swallowed the path. Every other status is about this request:
|
||||
// 400 carries the real refusals (unroutable point, over 1500 km apart) and 429
|
||||
// is the public instance's one-per-second.
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
return null
|
||||
}
|
||||
return runFrom(await response.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several legs read as one, the way a caller that only wants the whole drive sees it.
|
||||
*
|
||||
* The seam matters more than it looks. Every leg ends where the next begins, so a plain
|
||||
* concatenation repeats that vertex — and this line is not only drawn: a via dropped on
|
||||
* the route is projected onto it to work out which leg it belongs after, and that index
|
||||
* is written to the database. A repeated point can put the via on the wrong leg, which
|
||||
* is a stored wrong answer rather than an invisible one.
|
||||
*/
|
||||
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
|
||||
const coordinates: [number, number][] = []
|
||||
for (const leg of legs) {
|
||||
const last = coordinates[coordinates.length - 1]
|
||||
const first = leg.coordinates[0]
|
||||
const continues = last && first && last[0] === first[0] && last[1] === first[1]
|
||||
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
|
||||
}
|
||||
return {
|
||||
coordinates,
|
||||
distance: legs.reduce((sum, l) => sum + l.distance, 0),
|
||||
duration: legs.reduce((sum, l) => sum + l.duration, 0),
|
||||
// One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day
|
||||
// with a single toll booth in it would be labelled toll-free.
|
||||
hasToll: legs.some(l => l.hasToll),
|
||||
hasHighway: legs.some(l => l.hasHighway),
|
||||
hasFerry: legs.some(l => l.hasFerry),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Valhalla answer as a leg, or null when it is not one.
|
||||
*
|
||||
* Split out because it is the whole of the format knowledge and the only part worth
|
||||
* testing without a network: the native shape, not `format=osrm`. That flag exists and
|
||||
* would have kept the response side unchanged, but it drops `has_toll` — the one field
|
||||
* this is all for — and carries every intersection instead, which measured 5.7 times
|
||||
* larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one.
|
||||
*/
|
||||
/**
|
||||
* The answer read leg by leg, which is what a whole day needs.
|
||||
*
|
||||
* Valhalla gives every leg its own summary, so a chain comes back already divided the
|
||||
* way a road trip day wants it: this stop to the next, with its own distance, its own
|
||||
* drive time, and its own honest `has_toll`. Reading only the trip summary would flatten
|
||||
* a day into one number and lose the per-leg times the schedule is built from.
|
||||
*
|
||||
* The fallback to the trip summary is for a single leg only. Where several legs carry no
|
||||
* summary of their own there is no honest way to divide one total between them, and a
|
||||
* guess here would arrive as a wrong arrival time rather than as a missing one.
|
||||
*/
|
||||
export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
|
||||
const trip = tripOf(data)
|
||||
if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
for (const leg of trip.legs) {
|
||||
if (typeof leg.shape !== 'string') return null
|
||||
const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined)
|
||||
if (!summary) return null
|
||||
// Precision 6, verified against a known point: decoded as 5 the first vertex of a
|
||||
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
|
||||
const measure = measured(summary, decodePolyline(leg.shape, 6))
|
||||
if (!measure) return null
|
||||
legs.push(measure)
|
||||
}
|
||||
return { legs, total: joinLegs(legs) }
|
||||
}
|
||||
|
||||
interface ValhallaSummary { [key: string]: number | boolean | undefined }
|
||||
|
||||
function tripOf(data: unknown) {
|
||||
return (data as {
|
||||
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
|
||||
})?.trip
|
||||
}
|
||||
|
||||
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
|
||||
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
|
||||
if (!coordinates.length) return null
|
||||
const length = Number(summary.length)
|
||||
const time = Number(summary.time)
|
||||
if (!Number.isFinite(length) || !Number.isFinite(time)) return null
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
// Asked in kilometres, answered in kilometres, handed on in metres because that is
|
||||
// what every caller already reads off an OSRM route.
|
||||
distance: length * 1000,
|
||||
duration: time,
|
||||
hasToll: summary.has_toll === true,
|
||||
hasHighway: summary.has_highway === true,
|
||||
hasFerry: summary.has_ferry === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route actually avoids what it was asked to avoid.
|
||||
*
|
||||
* The reason this function exists rather than a boolean on the request: `use_tolls: 0`
|
||||
* is a weighting, so between two points with no untolled connection Valhalla answers a
|
||||
* tolled route and reports it honestly. Labelling that "No tolls" would be a lie on
|
||||
* screen, and the flags are in the answer precisely so nobody has to guess.
|
||||
*/
|
||||
export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean {
|
||||
if (avoid === 'toll') return !leg.hasToll
|
||||
if (avoid === 'motorway') return !leg.hasHighway
|
||||
return !leg.hasFerry
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The list both map renderers rebuild their via handles from.
|
||||
*
|
||||
* Its identity is the whole contract: the GL renderer replaces the DOM element when this
|
||||
* changes, and a pointerdown belongs to the element it happened on. A list that changed
|
||||
* on every render meant a freshly placed via lost its first drag to the re-route landing
|
||||
* underneath it.
|
||||
*/
|
||||
const via = (over: Partial<RoadtripVia> & { id: number }): RoadtripVia => ({
|
||||
day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1, ...over,
|
||||
})
|
||||
|
||||
describe('useStableVias', () => {
|
||||
it('FE-VIASTATE-001: the same vias in a fresh object are the same list', () => {
|
||||
// Exactly what a reload hands down: identical rows, a new object every time.
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 })] } })
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-002: a via that moved is a new list, so the handle is redrawn where it now is', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5, lat: 48.9 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current[0].lat).toBe(48.9)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-003: adding or removing one is a new list too', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 }), via({ id: 6, lat: 48.4 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-004: every day of the trip is in one list, and no vias is a stable empty one', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })], 2: [via({ id: 6, day_id: 2 })] } as Record<number, RoadtripVia[]> | undefined },
|
||||
})
|
||||
expect(result.current.map(v => v.id)).toEqual([5, 6])
|
||||
|
||||
rerender({ byDay: undefined })
|
||||
const empty = result.current
|
||||
expect(empty).toEqual([])
|
||||
rerender({ byDay: {} })
|
||||
expect(result.current).toBe(empty)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useRef } from 'react'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The trip's via points as an array that only changes when a via does.
|
||||
*
|
||||
* Both map renderers rebuild their via handles whenever this list changes, and the GL one
|
||||
* replaces the DOM element outright. The list it was given changed on every render: the
|
||||
* hook that loads vias returns a fresh object each time, and a fresh object arrives again
|
||||
* on every reload even when the rows came back identical.
|
||||
*
|
||||
* That made dragging a freshly placed via fail on the first try. Placing one triggers a
|
||||
* re-route, the re-route lands about a second later, the render it causes replaced the
|
||||
* handle under the pointer — and a pointerdown belongs to the element it happened on, so
|
||||
* the drag went to an element that no longer existed. The second attempt worked because
|
||||
* by then nothing was pending. An older via never showed it: nothing was re-routing.
|
||||
*
|
||||
* Keyed by id and position because those are the only fields a handle is built from. A
|
||||
* via that genuinely moved gets a new list, which is what redraws it where it now is.
|
||||
*/
|
||||
export function useStableVias(viasByDay: Record<number, RoadtripVia[]> | undefined): RoadtripVia[] {
|
||||
const flat = Object.values(viasByDay ?? {}).flat()
|
||||
const key = flat.map(v => `${v.id}@${v.lat},${v.lng}`).join('|')
|
||||
// Read and written during render on purpose: this is a cache of the argument, not
|
||||
// state — it never triggers a render of its own, and it has to be settled before the
|
||||
// effects that consume the list run.
|
||||
const held = useRef<{ key: string; list: RoadtripVia[] }>({ key: '', list: [] })
|
||||
if (held.current.key !== key) held.current = { key, list: flat }
|
||||
return held.current.list
|
||||
}
|
||||
@@ -353,6 +353,22 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
// A rental with stops is a drive, and the printout is what people take
|
||||
// into the car (#1797). Without this the route reads as pick-up and return
|
||||
// with everything in between missing.
|
||||
//
|
||||
// Consecutive repeats collapse, and a chain that names one place only is
|
||||
// dropped: the ordinary rental is picked up and returned at the same desk,
|
||||
// which satisfies "two endpoints" and would otherwise print the counter's
|
||||
// name twice with an arrow between. A genuine round trip keeps both ends,
|
||||
// because Dresden sits between them.
|
||||
const stops = (r.endpoints || []).slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => e.name)
|
||||
.filter((name, i, all) => name && name !== all[i - 1])
|
||||
subtitle = new Set(stops).size >= 2 ? stops.join(' → ') : ''
|
||||
}
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
@@ -16,6 +17,8 @@ interface Props {
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
@@ -47,7 +50,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
const myReq = ++reqIdRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
if (myReq !== reqIdRef.current) return
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
@@ -95,6 +95,8 @@ interface DayPlanSidebarProps {
|
||||
onSetRouteProfile?: (profile: string) => void
|
||||
onAddPlace?: () => void
|
||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||
/** Open the place form already pointed at this day, to create a new place there. */
|
||||
onCreatePlaceForDay?: (dayId: number) => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
@@ -146,6 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown = false,
|
||||
routeProfile = 'driving',
|
||||
@@ -1073,6 +1076,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1244,6 +1248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1892,7 +1897,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
{/* An empty day is where somebody wants to add something, so
|
||||
the slot offers to do it instead of only stating the fact.
|
||||
Without the handler (no edit rights) it stays the sentence. */}
|
||||
{onCreatePlaceForDay ? (
|
||||
<button type="button"
|
||||
onClick={e => { e.stopPropagation(); onCreatePlaceForDay(day.id) }}
|
||||
className="text-content-muted"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 12px', borderRadius: 8,
|
||||
// A solid outline and a whisper of tint rather than the
|
||||
// dashed grey placeholder it replaced: it is the one thing
|
||||
// to do on an empty day, so it should read as an offer
|
||||
// without competing with the day rows around it.
|
||||
background: 'color-mix(in srgb, var(--accent) 4%, transparent)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 10%, transparent)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 4%, transparent)' }}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} /> {t('dayplan.addPlaceHere')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { RouteSegment } from '../../types'
|
||||
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
|
||||
// its legs carry the profile-true durationText anyway, the icon just signals that
|
||||
// the time came from a plugin router (e.g. EV routing with charge time folded in).
|
||||
function profileIcon(profile: string) {
|
||||
export function profileIcon(profile: string) {
|
||||
if (profile === 'walking') return Footprints
|
||||
if (profile.startsWith('plugin:')) return Zap
|
||||
return Car
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, X } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
@@ -19,6 +20,8 @@ interface Props {
|
||||
|
||||
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [query, setQuery] = useState(value?.name || '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
@@ -49,7 +52,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
} catch {
|
||||
|
||||
@@ -169,6 +169,28 @@ describe('PlaceDetailsColumn', () => {
|
||||
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
|
||||
})
|
||||
|
||||
it('FE-PDC-010b: credits a website description by host', async () => {
|
||||
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
|
||||
// restaurant's own marketing copy would have been credited to an
|
||||
// encyclopaedia. There is no fixed name for "the place's own website", and
|
||||
// the host is the honest one.
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: {
|
||||
text: 'Pizza in Rostock, seit 2015.',
|
||||
source: 'website',
|
||||
sourceUrl: 'https://www.losteria.net/rostock',
|
||||
license: null,
|
||||
},
|
||||
})
|
||||
renderColumn()
|
||||
|
||||
const link = await screen.findByRole('link', { name: /losteria\.net/ })
|
||||
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
|
||||
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
|
||||
renderColumn()
|
||||
@@ -445,41 +467,33 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
})
|
||||
})
|
||||
/**
|
||||
* FE-PDC-027..029 — the nudge towards a Google key.
|
||||
* FE-PDC-027 — nothing came back for this place.
|
||||
*
|
||||
* Deliberately narrow: it appears only when the free sources came up empty AND
|
||||
* no key is configured. On an instance with a key there is nothing to suggest,
|
||||
* and on a place the free sources described it would read as an advert.
|
||||
* It used to be a line of grey text plus, when no Google key was set, a panel
|
||||
* telling the reader to go and ask an administrator for one. The mascot says the
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — no Google key', () => {
|
||||
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
renderColumn()
|
||||
|
||||
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
|
||||
empty()
|
||||
renderColumn({ hasMapsKey: false })
|
||||
|
||||
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
|
||||
empty()
|
||||
renderColumn({ hasMapsKey: true })
|
||||
|
||||
await screen.findByText('places.details.nothing')
|
||||
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-029: stays quiet when the free sources did find something', async () => {
|
||||
it('FE-PDC-028: stays quiet when the free sources did find something', async () => {
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
|
||||
})
|
||||
renderColumn({ hasMapsKey: false })
|
||||
renderColumn()
|
||||
|
||||
await screen.findByText('Ein Museum.')
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ImageOff,
|
||||
Landmark,
|
||||
Leaf,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Sprout,
|
||||
Star,
|
||||
Sun,
|
||||
@@ -27,6 +25,7 @@ import { mapsApi } from '../../api/client'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/** The place the column is describing. Null while nothing is selected. */
|
||||
@@ -53,7 +52,6 @@ interface PlaceDetailsColumnProps {
|
||||
/** For grouping the rating count's digits. */
|
||||
locale?: string
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
hasMapsKey?: boolean
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -119,7 +117,6 @@ export default function PlaceDetailsColumn({
|
||||
language,
|
||||
timeFormat = '24h',
|
||||
locale = 'en-US',
|
||||
hasMapsKey = false,
|
||||
t,
|
||||
}: PlaceDetailsColumnProps): React.ReactElement {
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
@@ -208,7 +205,17 @@ export default function PlaceDetailsColumn({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5">
|
||||
{!selection && <p className="text-caption text-content-muted">{t('places.details.empty')}</p>}
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. */}
|
||||
{!selection && (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection && state === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-caption text-content-muted">
|
||||
@@ -223,17 +230,20 @@ export default function PlaceDetailsColumn({
|
||||
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
|
||||
)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-caption text-content-muted">
|
||||
<ImageOff className="w-4 h-4 shrink-0" />
|
||||
{t('places.details.nothing')}
|
||||
</div>
|
||||
{/* Only when both are true. With a key configured there is nothing
|
||||
to suggest, and on a place the free sources DID describe the
|
||||
suggestion would be an advert. */}
|
||||
{!hasMapsKey && <NoKeyHint t={t} />}
|
||||
</div>
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -379,6 +389,37 @@ function PhotoTile({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the description came from, as the reader would name them.
|
||||
*
|
||||
* `website` has no fixed name — it is the place's own site — so it is credited
|
||||
* by host: "losteria.net" says where the text is from far better than a generic
|
||||
* word would. Deliberately not a ternary chain: the previous one fell through
|
||||
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
|
||||
* encyclopaedia.
|
||||
*/
|
||||
function descriptionSourceLabel(
|
||||
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
|
||||
sourceUrl: string | null,
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'google':
|
||||
return 'Google'
|
||||
case 'osm':
|
||||
return 'OpenStreetMap'
|
||||
case 'wikivoyage':
|
||||
return 'Wikivoyage'
|
||||
case 'wikipedia':
|
||||
return 'Wikipedia'
|
||||
case 'website':
|
||||
try {
|
||||
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return 'Website'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
|
||||
if (source === 'google') return 'Google'
|
||||
if (source === 'wikipedia') return 'Wikipedia'
|
||||
@@ -431,19 +472,6 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
|
||||
* It also names who to ask: the key is an instance-wide setting, so on most
|
||||
* installs the person reading this cannot act on it themselves.
|
||||
*/
|
||||
function NoKeyHint({ t }: { t: TranslationFn }): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
</span>
|
||||
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t('places.details.noKeyHint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The star rating, as stars.
|
||||
@@ -659,14 +687,7 @@ function DescriptionBlock({
|
||||
}): React.ReactElement | null {
|
||||
if (!description) return null
|
||||
|
||||
const sourceLabel =
|
||||
description.source === 'google'
|
||||
? 'Google'
|
||||
: description.source === 'osm'
|
||||
? 'OpenStreetMap'
|
||||
: description.source === 'wikivoyage'
|
||||
? 'Wikivoyage'
|
||||
: 'Wikipedia'
|
||||
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
|
||||
|
||||
// A description of the chain is not a description of this place, and the
|
||||
// heading is where that gets said. Without it the reader has no way to tell
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
export interface PlaceFormData {
|
||||
name: string
|
||||
description: string
|
||||
@@ -24,6 +26,14 @@ export interface PlaceFormData {
|
||||
// submit payload when unchanged, and useTripPlanner strips it off the place
|
||||
// update and PUTs it per assignment instead.
|
||||
assignment_notes?: string
|
||||
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
|
||||
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
|
||||
// place created outside a road trip has no opinion about fuel stops.
|
||||
stop_type?: RoadtripStopType | null
|
||||
// Offered only while creating, and only alongside a stop_type: the corridor popup
|
||||
// suggests how long that kind of pause usually takes, and the full form should not
|
||||
// throw the suggestion away on the way through. Editing a stay is the rail's job.
|
||||
duration_minutes?: number
|
||||
}
|
||||
|
||||
export function isGoogleMapsUrl(input: string): boolean {
|
||||
|
||||
@@ -299,6 +299,43 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => {
|
||||
// The layer's second line is the name written on the building, not an
|
||||
// address. Joining the two and searching for it asks a question nobody
|
||||
// typed, and whatever came back first was taken as the place the user had
|
||||
// already picked. The row carries coordinates, so the fallback uses those.
|
||||
const user = userEvent.setup();
|
||||
let searched = 0;
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{
|
||||
placeId: 'node:9712313',
|
||||
mainText: 'Tokio Hauptbahnhof',
|
||||
secondaryText: '東京駅丸の内駅舎',
|
||||
source: 'openstreetmap',
|
||||
lat: 35.6811816,
|
||||
lng: 139.76598265,
|
||||
}],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () => {
|
||||
searched += 1;
|
||||
return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio');
|
||||
await user.click(await screen.findByText('Tokio Hauptbahnhof'));
|
||||
|
||||
expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument();
|
||||
expect(searched).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
@@ -349,10 +386,80 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||
// hasMapsKey is false by default in beforeEach
|
||||
it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => {
|
||||
// It used to say "Using OpenStreetMap" above the box whenever no Google key
|
||||
// was set, which was a sentence about configuration in the middle of a form
|
||||
// about a place. Each result names its own source now, which is the question
|
||||
// people actually had.
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByText(/OpenStreetMap/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
||||
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => {
|
||||
// The keystroke path asks both indexes at once, so the name the response
|
||||
// carries for the whole list ('trek-places') is true of the call and wrong
|
||||
// for half the rows. Marking every suggestion TREK is how a list that did
|
||||
// contain OpenStreetMap places read as if it never had.
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [
|
||||
{ placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' },
|
||||
{ placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' },
|
||||
],
|
||||
source: 'trek-places',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station');
|
||||
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
|
||||
// The whole point of putting it on the row: with both indexes answering,
|
||||
// a label above the list could only name one of them. The index marks its
|
||||
// own rows, so the unmarked one is OpenStreetMap by elimination.
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [
|
||||
{ name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' },
|
||||
{ name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' },
|
||||
],
|
||||
source: 'trek-places+openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
||||
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Category ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { recordPlacePick } from '../../api/placeShadow'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import CollectionPicker from '../Collections/CollectionPicker'
|
||||
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -16,6 +18,8 @@ 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'
|
||||
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
||||
@@ -36,7 +40,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
@@ -52,6 +56,32 @@ interface PlaceFormModalProps {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* One row of the typed-ahead list, as the server sends it.
|
||||
*
|
||||
* `source`, `lat` and `lng` are optional because not every index fills them:
|
||||
* Google answers with neither, and the mark falls back to the name the whole
|
||||
* list carries.
|
||||
*/
|
||||
type Suggestion = {
|
||||
placeId: string
|
||||
mainText: string
|
||||
secondaryText: string
|
||||
source?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
|
||||
function SourceBadge({ label }: { label: string | null }) {
|
||||
if (!label) return null
|
||||
return (
|
||||
<span className="shrink-0 rounded-md border border-edge bg-surface-secondary px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||
* render over the form fields. */
|
||||
@@ -86,9 +116,21 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
|
||||
onOpenExpense,
|
||||
} = 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 [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
|
||||
* state because nothing renders from it and a pick must read the value that
|
||||
* belonged to the list, not a value a re-render replaced.
|
||||
*/
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
@@ -103,7 +145,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
const [acSuggestions, setAcSuggestions] = useState<Suggestion[]>([])
|
||||
// Which index answered the last keystroke, for the rows that do not say so
|
||||
// themselves. Google and the OpenStreetMap fallback each answer from one
|
||||
// place; the index path answers from two at once and marks every row.
|
||||
const [acSource, setAcSource] = useState<string>('')
|
||||
const [acHighlight, setAcHighlight] = useState(-1)
|
||||
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const acAbortRef = useRef<AbortController | null>(null)
|
||||
@@ -143,6 +189,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
// Carried through every edit. Without it, opening a fuel stop to fix a typo
|
||||
// submits an empty kind and turns it back into a numbered destination.
|
||||
// duration_minutes deliberately stays out: how long a stay takes belongs to the
|
||||
// rail's own dialog, and sending it from here would overwrite what was set there.
|
||||
stop_type: place.stop_type ?? null,
|
||||
// The day-specific note rides only with an assignment in context (#2163);
|
||||
// otherwise the key stays absent so submit never sends a notes write.
|
||||
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
|
||||
@@ -157,6 +208,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
website: prefillCoords.website || '',
|
||||
phone: prefillCoords.phone || '',
|
||||
osm_id: prefillCoords.osm_id,
|
||||
stop_type: prefillCoords.stop_type ?? null,
|
||||
duration_minutes: prefillCoords.duration_minutes,
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
@@ -213,32 +266,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
const places = useTripStore((s) => s.places)
|
||||
const locationBias = useMemo(() => {
|
||||
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
|
||||
if (withCoords.length === 0) return undefined
|
||||
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
for (const p of withCoords) {
|
||||
const lat = Number(p.lat), lng = Number(p.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lat > maxLat) maxLat = lat
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lng > maxLng) maxLng = lng
|
||||
}
|
||||
if (!Number.isFinite(minLat)) return undefined
|
||||
|
||||
// Skip bias if the bounding box is too large (~500 km diagonal)
|
||||
const dlat = maxLat - minLat
|
||||
const dlng = maxLng - minLng
|
||||
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
||||
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
|
||||
if (diagKm > 500) return undefined
|
||||
|
||||
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||
}, [places])
|
||||
// Where the trip is happening — the hint that tells the search which of a
|
||||
// thousand identically named places is meant. Autocomplete wants a box, the
|
||||
// search wants a point; useLocationBias derives both from the same places.
|
||||
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
|
||||
|
||||
// Autocomplete fetch — aborts any in-flight request before starting a new one
|
||||
const fetchSuggestions = useCallback(async (query: string) => {
|
||||
@@ -252,7 +284,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
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
|
||||
@@ -310,8 +344,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
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 {
|
||||
@@ -319,7 +355,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectMapsResult = (result) => {
|
||||
/**
|
||||
* `pick` is present only when the click came from a ranked list. The
|
||||
* collection picker and the autocomplete detour reach this function with a
|
||||
* place that was never ranked against a query, and a made-up rank would be
|
||||
* worse than no row at all.
|
||||
*/
|
||||
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
|
||||
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
|
||||
// The one point every pick flows through, so the detail column hangs here.
|
||||
// A new pick drops whatever hero image belonged to the previous place.
|
||||
@@ -336,12 +378,36 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
details: result,
|
||||
})
|
||||
setForm(prev => ({ ...prev, image_url: undefined }))
|
||||
if (pick) {
|
||||
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
|
||||
if (meta) {
|
||||
recordPlacePick({
|
||||
query: meta.query,
|
||||
lang: language,
|
||||
// The bias the search actually ran under is a box around the trip's
|
||||
// existing places; the corpus stores its centre, which is what an
|
||||
// evaluation needs to bias its own index the same way.
|
||||
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
|
||||
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
|
||||
source: `${pick.mode}:${meta.source}`,
|
||||
liveRank: pick.rank,
|
||||
liveCount: pick.count,
|
||||
pickedName: result.name || '',
|
||||
pickedLat: lat,
|
||||
pickedLng: lng,
|
||||
pickedPlaceId: result.google_place_id || result.osm_id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
const handleSelectSuggestion = async (suggestion: Suggestion) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
@@ -366,13 +432,28 @@ 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)
|
||||
const search = await mapsApi.search(query, language, locationBiasPoint)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -479,6 +560,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lat: form.lat ? Number.parseFloat(form.lat) : null,
|
||||
lng: form.lng ? Number.parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
// An explicit null is how a stop stops being a fuel stop; the service reads it
|
||||
// that way rather than as "leave alone", which is what a missing key means.
|
||||
stop_type: form.stop_type || null,
|
||||
// Only on the way in, and only with a kind: it is the popup's suggestion for how
|
||||
// long that kind of pause takes. On an edit it is left out entirely, because the
|
||||
// stay belongs to the rail's dialog and sending it here would overwrite it.
|
||||
...(!place && form.stop_type && form.duration_minutes
|
||||
? { duration_minutes: form.duration_minutes }
|
||||
: {}),
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
}
|
||||
// #2163: the per-assignment note only travels when an assignment is in
|
||||
@@ -546,6 +636,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
fileRef,
|
||||
acSuggestions,
|
||||
setAcSuggestions,
|
||||
acSource,
|
||||
searchSource,
|
||||
acHighlight,
|
||||
setAcHighlight,
|
||||
acDebounceRef,
|
||||
@@ -618,6 +710,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
fileRef,
|
||||
acSuggestions,
|
||||
setAcSuggestions,
|
||||
acSource,
|
||||
searchSource,
|
||||
acHighlight,
|
||||
setAcHighlight,
|
||||
acDebounceRef,
|
||||
@@ -699,18 +793,12 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
language={language}
|
||||
timeFormat={S.timeFormat}
|
||||
locale={S.locale}
|
||||
hasMapsKey={S.hasMapsKey}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
|
||||
{/* Place Search */}
|
||||
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
|
||||
{!hasMapsKey && (
|
||||
<p className="mb-2 text-xs text-content-faint">
|
||||
{t('places.osmActive')}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -751,10 +839,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
idx === acHighlight ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{s.mainText}</div>
|
||||
{s.secondaryText && (
|
||||
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">{s.mainText}</div>
|
||||
{s.secondaryText && (
|
||||
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
|
||||
)}
|
||||
</div>
|
||||
<SourceBadge label={sourceLabelFor(s, acSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -768,11 +861,16 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleSelectMapsResult(result)}
|
||||
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
|
||||
>
|
||||
<div className="font-medium text-sm">{result.name}</div>
|
||||
<div className="text-xs text-content-muted truncate">{result.address}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">{result.name}</div>
|
||||
<div className="text-xs text-content-muted truncate">{result.address}</div>
|
||||
</div>
|
||||
<SourceBadge label={sourceLabelFor(result, searchSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,39 @@ describe('PlacesSidebar', () => {
|
||||
expect(onAddPlace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* The split add button (a day is open).
|
||||
*
|
||||
* Two buttons rather than one, because the pool sits a click away from the plan
|
||||
* and a place you already know belongs to today should not need a second trip
|
||||
* through the day picker. The second one stays mounted and collapsed so it has
|
||||
* something to animate out of.
|
||||
*/
|
||||
it('FE-COMP-PLACES-005b: with no day open there is one add button, at full length', () => {
|
||||
render(<PlacesSidebar {...defaultProps} selectedDayId={null} onAddPlaceToSelectedDay={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('New place')).not.toBeInTheDocument();
|
||||
// Present in the DOM so it can animate, but out of reach until a day is open.
|
||||
const quick = screen.getByTestId('add-place-to-day');
|
||||
expect(quick).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(quick).toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACES-005c: an open day splits the button and shortens the main label', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddPlaceToSelectedDay = vi.fn();
|
||||
render(<PlacesSidebar {...defaultProps} selectedDayId={7} onAddPlaceToSelectedDay={onAddPlaceToSelectedDay} />);
|
||||
|
||||
// Shortened so both fit side by side without either truncating.
|
||||
expect(screen.getByText('New place')).toBeInTheDocument();
|
||||
|
||||
const quick = screen.getByTestId('add-place-to-day');
|
||||
expect(quick).toHaveAttribute('tabindex', '0');
|
||||
await user.click(quick);
|
||||
expect(onAddPlaceToSelectedDay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACES-006: clicking a place calls onPlaceClick with place id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlaceClick = vi.fn();
|
||||
@@ -255,6 +288,113 @@ describe('Filter tabs', () => {
|
||||
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Unplanned Place')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── The open day narrows the pool, and says so ──────────────────────────────
|
||||
//
|
||||
// The map has followed the selected day on this filter since #2024 while the list
|
||||
// did not, so a trip with everything planned read "55" in the pool beside five pins
|
||||
// on the map. Reported twice in the same Discord thread as the map being broken.
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-052: with a day open, "Planned" shows that day, not the whole trip', () => {
|
||||
const today = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const assignments = {
|
||||
'1': [buildAssignment({ place: today, day_id: 1 })],
|
||||
'2': [buildAssignment({ place: otherDay, day_id: 2 })],
|
||||
};
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today, otherDay]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('On This Day')).toBeInTheDocument();
|
||||
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-053: without a day open it is the whole trip again', () => {
|
||||
const dayOne = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const dayTwo = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const assignments = {
|
||||
'1': [buildAssignment({ place: dayOne, day_id: 1 })],
|
||||
'2': [buildAssignment({ place: dayTwo, day_id: 2 })],
|
||||
};
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[dayOne, dayTwo]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('On This Day')).toBeInTheDocument();
|
||||
expect(screen.getByText('On Another Day')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-054: the note only appears while the day is actually narrowing', () => {
|
||||
const today = buildPlace({ id: 91, name: 'On This Day' });
|
||||
const assignments = { '1': [buildAssignment({ place: today, day_id: 1 })] };
|
||||
const onClearSelectedDay = vi.fn();
|
||||
seedStore(useTripStore, { placesFilter: 'planned' });
|
||||
const { rerender } = render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }] as never}
|
||||
selectedDayId={1}
|
||||
onClearSelectedDay={onClearSelectedDay}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Showing the open day only')).toBeInTheDocument();
|
||||
|
||||
// Dismissing it asks for the day to be closed rather than changing the filter.
|
||||
fireEvent.click(screen.getByLabelText('Show the whole trip'));
|
||||
expect(onClearSelectedDay).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No day open, nothing narrowed, no note.
|
||||
rerender(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[today]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }] as never}
|
||||
selectedDayId={null}
|
||||
onClearSelectedDay={onClearSelectedDay}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-055: "Unplanned" stays trip-wide while a day is open', () => {
|
||||
// A place assigned to some other day is planned, whichever day happens to be open,
|
||||
// so it must not reappear in the unplanned pool.
|
||||
const otherDay = buildPlace({ id: 92, name: 'On Another Day' });
|
||||
const loose = buildPlace({ id: 93, name: 'Not Planned At All' });
|
||||
const assignments = { '2': [buildAssignment({ place: otherDay, day_id: 2 })] };
|
||||
seedStore(useTripStore, { placesFilter: 'unplanned' });
|
||||
render(
|
||||
<PlacesSidebar
|
||||
{...defaultProps}
|
||||
places={[otherDay, loose]}
|
||||
assignments={assignments}
|
||||
days={[{ id: 1 }, { id: 2 }] as never}
|
||||
selectedDayId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Not Planned At All')).toBeInTheDocument();
|
||||
expect(screen.queryByText('On Another Day')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,7 +43,19 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
<PlacesSelectionBar {...S} />
|
||||
) : (
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
{/* A badge across the whole rail rather than a line of text hugging the
|
||||
left edge: it reads as the list's header instead of as a stray label.
|
||||
Outlined rather than filled, because the tertiary surface is a slate
|
||||
tone and put a blue cast on the panel. */}
|
||||
<div className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '4px 10px', borderRadius: 99,
|
||||
background: 'transparent', border: '1px solid var(--border-faint)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
}}>
|
||||
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star } from 'lucide-react'
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
/**
|
||||
* Below this the two labels stop fitting side by side and both buttons fall back
|
||||
* to their icon. Measured on the button row itself rather than derived from the
|
||||
* sidebar width: the labels are translated, so how much room they need differs
|
||||
* per locale, and the row is what actually runs out of space. The rail is
|
||||
* draggable down to 200px, which leaves the row 168.
|
||||
*/
|
||||
const COMPACT_BUTTONS_WIDTH = 232
|
||||
|
||||
export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -21,52 +31,118 @@ export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
|
||||
export function PlacesHeader(S: SidebarState) {
|
||||
const {
|
||||
canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
|
||||
canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
|
||||
places, categories, categoryFilters, search, setSearch, plannedIds, plannedFilterIds, dayScoped, onClearSelectedDay, hasTracks,
|
||||
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
|
||||
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
|
||||
ratingFilter, setRatingFilter,
|
||||
starDropOpen, setStarDropOpen,
|
||||
} = S
|
||||
const dayOpen = selectedDayId != null
|
||||
const { ref: buttonRowRef, width: buttonRowWidth } = useElementSize<HTMLDivElement>()
|
||||
// Zero is the first paint, before the observer has measured anything — treat
|
||||
// that as roomy so the labels do not flash away and back on every mount.
|
||||
const compact = buttonRowWidth > 0 && buttonRowWidth < COMPACT_BUTTONS_WIDTH
|
||||
const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace')
|
||||
const fileImportLabel = t('places.importFile')
|
||||
const listImportLabel = t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
|
||||
{canEditPlaces && <button type="button"
|
||||
onClick={onAddPlace}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>}
|
||||
{canEditPlaces && <div ref={buttonRowRef} style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
{/* The label shortens while the second button is out, so both fit side by
|
||||
side in a default rail without either one truncating; a squeezed rail
|
||||
drops it entirely and the aria-label carries the name instead. */}
|
||||
<button type="button"
|
||||
onClick={onAddPlace}
|
||||
aria-label={compact ? addLabel : undefined}
|
||||
title={compact ? addLabel : undefined}
|
||||
className="bg-accent text-accent-text motion-reduce:transition-none"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
// Both halves are driven by flex-basis rather than by grow, so the
|
||||
// pair lands on an exact 50/50 and the handover animates in both
|
||||
// directions. Half of the 6px gap comes off each side.
|
||||
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '100%',
|
||||
minWidth: 0, padding: '8px 12px', borderRadius: 12, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
transition: 'flex-basis 180ms ease',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{!compact && (
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{addLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Kept mounted and collapsed rather than unmounted, so it has something
|
||||
to animate out of: a button that only exists while a day is open
|
||||
would appear and vanish on the spot. Hidden from the tab order and
|
||||
from screen readers in the same breath, because a zero-width button
|
||||
is still focusable otherwise. */}
|
||||
{onAddPlaceToSelectedDay && (
|
||||
<Tooltip label={t('places.addToSelectedDay')} disabled={!dayOpen}>
|
||||
<button type="button"
|
||||
onClick={onAddPlaceToSelectedDay}
|
||||
aria-label={t('places.addToSelectedDay')}
|
||||
data-testid="add-place-to-day"
|
||||
aria-hidden={!dayOpen}
|
||||
tabIndex={dayOpen ? 0 : -1}
|
||||
className="bg-accent text-accent-text motion-reduce:transition-none"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '0%',
|
||||
minWidth: 0,
|
||||
opacity: dayOpen ? 1 : 0,
|
||||
padding: dayOpen ? '8px 12px' : 0,
|
||||
borderRadius: 12, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
pointerEvents: dayOpen ? 'auto' : 'none',
|
||||
transition: 'flex-basis 180ms ease, opacity 140ms ease, padding 180ms ease',
|
||||
}}
|
||||
>
|
||||
<CalendarPlus size={14} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
{!compact && t('places.addToDayShort')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>}
|
||||
{canEditPlaces && <>
|
||||
{/* Same squeeze rule as the row above, measured off the same rail: once the
|
||||
add buttons lose their labels these two would not fit either. */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setFileImportOpen(true)}
|
||||
aria-label={compact ? fileImportLabel : undefined}
|
||||
title={compact ? fileImportLabel : undefined}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
<FileDown size={11} strokeWidth={2} /> {!compact && fileImportLabel}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setListImportOpen(true)}
|
||||
aria-label={compact ? listImportLabel : undefined}
|
||||
title={compact ? listImportLabel : undefined}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
<MapPin size={11} strokeWidth={2} /> {!compact && listImportLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
|
||||
@@ -87,7 +163,11 @@ export function PlacesHeader(S: SidebarState) {
|
||||
const counts = {
|
||||
all: baseFiltered.length,
|
||||
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
|
||||
planned: baseFiltered.filter(p => plannedIds.has(p.id)).length,
|
||||
// While a day is open this counts that day's plan, the same set the list and
|
||||
// the map show. Counting the whole trip here is what made the tab read 55
|
||||
// beside five pins, with nothing to say the two were answering different
|
||||
// questions.
|
||||
planned: baseFiltered.filter(p => plannedFilterIds.has(p.id)).length,
|
||||
tracks: baseFiltered.filter(p => p.route_geometry).length,
|
||||
}
|
||||
const tabs = ([
|
||||
@@ -97,7 +177,11 @@ export function PlacesHeader(S: SidebarState) {
|
||||
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
|
||||
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }>
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
// The group always spans the rail and never wraps onto a second line.
|
||||
// Each tab keeps its own text width and only the leftover space is
|
||||
// shared out, so "Unplanned" stays wider than "All" instead of the four
|
||||
// being forced to one size.
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'nowrap' }}>
|
||||
{tabs.map(f => {
|
||||
const active = filter === f.id
|
||||
return (
|
||||
@@ -107,18 +191,20 @@ export function PlacesHeader(S: SidebarState) {
|
||||
className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '4px 9px', borderRadius: 99,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
|
||||
padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap',
|
||||
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.label}</span>
|
||||
<span className={active ? 'text-accent-text' : 'text-content-faint'} style={{
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1,
|
||||
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
|
||||
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{counts[f.id]}
|
||||
</span>
|
||||
@@ -129,6 +215,42 @@ export function PlacesHeader(S: SidebarState) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Says out loud what the count above already narrowed to.
|
||||
The map has followed the open day on this filter since #2024, and until now
|
||||
nothing anywhere said so: the pool read 55, the map drew five, and the honest
|
||||
conclusion was that the map was broken. It sits under the tabs rather than on
|
||||
the map because a chip over the canvas is unreachable on a phone, which is
|
||||
where this was reported from. */}
|
||||
{dayScoped && (
|
||||
<div
|
||||
className="border border-edge-faint bg-surface-tertiary text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 6px 4px 9px', borderRadius: 8, marginBottom: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={11} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t('places.dayScoped')}
|
||||
</span>
|
||||
{onClearSelectedDay && (
|
||||
<button type="button"
|
||||
onClick={onClearSelectedDay}
|
||||
aria-label={t('places.dayScopedClear')}
|
||||
title={t('places.dayScopedClear')}
|
||||
className="text-content-faint hover:text-content"
|
||||
style={{
|
||||
marginInlineStart: 'auto', display: 'flex', alignItems: 'center',
|
||||
background: 'none', border: 'none', padding: 3, cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<X size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suchfeld */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
|
||||
@@ -137,10 +259,14 @@ export function PlacesHeader(S: SidebarState) {
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
placeholder={t('places.search')}
|
||||
className="bg-surface-tertiary text-content"
|
||||
className="bg-surface-card text-content"
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
// Borderless on the tertiary surface it sat almost flush with the
|
||||
// panel; the outline is the one the category dropdown below already
|
||||
// uses, so the two read as the same kind of control.
|
||||
border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment } from 'react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { MemoPlaceRow } from './PlacesSidebarRow'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
@@ -14,14 +15,26 @@ export function PlacesList(S: SidebarState) {
|
||||
return (
|
||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
</span>
|
||||
{canEditPlaces && <button type="button" onClick={onAddPlace} className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('places.addPlace')}
|
||||
</button>}
|
||||
</div>
|
||||
/* The mascot and one line, the shape every other empty state in TREK has.
|
||||
The add link stays as the state's action: an empty list of places is
|
||||
one of the few that has an obvious next step. */
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
title={filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
action={canEditPlaces ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddPlace}
|
||||
className="text-caption text-content underline underline-offset-2 hover:text-accent"
|
||||
>
|
||||
{t('places.addPlace')}
|
||||
</button>
|
||||
) : undefined}
|
||||
/>
|
||||
) : (
|
||||
filtered.map(place => {
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
|
||||
@@ -43,6 +43,9 @@ export function ListImportModal(S: SidebarState) {
|
||||
)}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
|
||||
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
|
||||
{/* Same box, same Share button: which screen the link came from is the URL's
|
||||
business, and until it said so nobody knew a route could be pasted here. */}
|
||||
{listImportProvider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -175,6 +175,29 @@ describe('TransitSearchPanel', () => {
|
||||
await user.click(await screen.findByText(/London Victoria/))
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty result is ambiguous without the backend's name: the fallback to
|
||||
* Transitous is silent, so "no connections" could mean the provider has no
|
||||
* data here or that the provider you picked never ran (#1699).
|
||||
*/
|
||||
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Accommodation } from '../../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -36,6 +37,14 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str
|
||||
|
||||
export interface PickedPlace { name: string; lat: number; lng: number }
|
||||
|
||||
// Backend names as they are written, not translated — the empty state says
|
||||
// which one answered so "nothing here" can be told apart from "not the one you
|
||||
// picked" (#1699).
|
||||
const PROVIDER_NAMES: Record<TransitProvider, string> = {
|
||||
transitous: 'Transitous',
|
||||
google: 'Google',
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
|
||||
@@ -340,7 +349,7 @@ interface TransitSearchPanelProps {
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -355,6 +364,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
const [addingIdx, setAddingIdx] = useState<number | null>(null)
|
||||
const [provider, setProvider] = useState<TransitProvider | null>(null)
|
||||
|
||||
// Quick picks: the day's located places, plus the trip's located accommodations.
|
||||
const quickPicks = useMemo<PickedPlace[]>(() => {
|
||||
@@ -387,6 +397,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
setLoading(true)
|
||||
setItineraries(null)
|
||||
setExpandedIdx(null)
|
||||
setProvider(null)
|
||||
try {
|
||||
const tzFrom = tzAt(from.lat, from.lng)
|
||||
const tzTo = tzAt(to.lat, to.lng)
|
||||
@@ -395,7 +406,11 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
|
||||
const allModes = activeModes.size === MODE_GROUPS.length
|
||||
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language })
|
||||
// Which backend actually answered (#1699). Named in the empty state so
|
||||
// "nothing found" can be told apart from "the provider you picked never
|
||||
// ran" — the fallback to Transitous is silent by design.
|
||||
setProvider(d.provider ?? null)
|
||||
// MOTIS names the request coordinates START/END — swap in the places the
|
||||
// user actually picked so walks read "Walk to Zoologischer Garten".
|
||||
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
|
||||
@@ -606,7 +621,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
)}
|
||||
{!loading && ranked && ranked.length === 0 && (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('transit.noResults')}
|
||||
{provider
|
||||
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
|
||||
: t('transit.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{!loading && ranked && ranked.length > 0 && (
|
||||
|
||||
@@ -1192,6 +1192,93 @@ describe('TransportModal', () => {
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
|
||||
// Pick-up and return are the rental frame; the stops sit between them.
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
// Pick-up and return come first in the form, the stops follow — so the saved
|
||||
// route is from · stop · stop · to even though the fields read in another order.
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
expect(fields).toHaveLength(4);
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
// The order of the stops is the order they are driven, and `sequence` is derived
|
||||
// from it on save — so without this the only way to swap two was to retype both.
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
// A single stop has nothing to swap with, so the buttons stay away entirely.
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const up = screen.getAllByRole('button', { name: /Move up/i });
|
||||
const down = screen.getAllByRole('button', { name: /Move down/i });
|
||||
expect(up[0]).toBeDisabled();
|
||||
expect(down[down.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
|
||||
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
|
||||
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -116,6 +116,19 @@ interface StationWaypointForm {
|
||||
seat: string
|
||||
confirmation_number: string
|
||||
}
|
||||
// ── Car stops along the drive ──────────────────────────────────────────────
|
||||
//
|
||||
// A rental is one booking with one pick-up and one return, so — unlike a flight or a
|
||||
// train — the stops in between are not legs of their own: they are places the drive
|
||||
// passes through, each with an optional time the driver plans to be there. They persist
|
||||
// as the same `role: 'stop'` endpoints every other transport type already uses.
|
||||
interface CarStopForm {
|
||||
location: LocationPoint | null
|
||||
time: string
|
||||
}
|
||||
|
||||
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
|
||||
|
||||
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
|
||||
}
|
||||
@@ -200,6 +213,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
// Train route as an ordered list of stations (origin .. stops .. destination).
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
// A car keeps its pick-up and return as the frame of the rental and gains the stops
|
||||
// in between (#1797): one booking, one continuous drive, several places along it.
|
||||
const [carStops, setCarStops] = useState<CarStopForm[]>([])
|
||||
|
||||
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
|
||||
const moveCarStop = (index: number, delta: number): void => {
|
||||
setCarStops(prev => {
|
||||
const to = index + delta
|
||||
if (to < 0 || to >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
;[next[index], next[to]] = [next[to], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -346,6 +373,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
// Stops persist for every type; only a car offers an editor for them, so only a
|
||||
// car reads them back into one. The others keep passing theirs through untouched.
|
||||
setCarStops(
|
||||
src.type === 'car'
|
||||
? (src.endpoints ?? [])
|
||||
.filter(e => e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
|
||||
: [],
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
@@ -354,6 +392,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
|
||||
setCarStops([])
|
||||
}
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
@@ -504,8 +543,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
})
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
|
||||
const stops = keepTransit
|
||||
// A car writes the stops the driver planned; every other type keeps passing the
|
||||
// itinerary's transfer stops through while the route is unchanged (#1065).
|
||||
const carEndpoints = form.type === 'car'
|
||||
? carStops
|
||||
.filter(s => s.location)
|
||||
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
|
||||
: []
|
||||
const stops = keepTransit && form.type !== 'car'
|
||||
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||||
: []
|
||||
stops.forEach((s, i) => endpoints.push({
|
||||
@@ -513,7 +558,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
|
||||
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
|
||||
}))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
|
||||
carEndpoints.forEach(e => endpoints.push(e))
|
||||
const stopCount = stops.length + carEndpoints.length
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
// Flights and trains derive their span from the first/last waypoint; other
|
||||
@@ -955,6 +1002,76 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
|
||||
pick-up and return; these are the places in between, in order. */}
|
||||
{form.type === 'car' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('roadtrip.stops.label')}</label>
|
||||
{carStops.map((stop, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
{/* The order of these stops IS the route: `sequence` is the array index
|
||||
at save time, so the only way to change which one is driven first
|
||||
was to delete both and re-enter them. Buttons rather than dragging,
|
||||
because the row already carries a location picker and a time picker
|
||||
and there is nothing left to grab. */}
|
||||
{carStops.length > 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === 0 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, 1)}
|
||||
disabled={i === carStops.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === carStops.length - 1 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocationSelect
|
||||
value={stop.location}
|
||||
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 110, flexShrink: 0 }}>
|
||||
<CustomTimePicker
|
||||
value={stop.time}
|
||||
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
|
||||
aria-label={t('roadtrip.stops.remove')}
|
||||
className="text-content-faint hover:text-danger"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 8, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
|
||||
className="text-content-faint hover:text-content-secondary"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
|
||||
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
|
||||
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
|
||||
@@ -31,6 +31,21 @@ export interface PlacesSidebarProps {
|
||||
selectedPlaceId: number | null
|
||||
onPlaceClick: (placeId: number | null) => void
|
||||
onAddPlace: () => void
|
||||
/**
|
||||
* Create a place and drop it straight into the day that is open.
|
||||
*
|
||||
* Only reachable while a day is selected, which is what the split button in
|
||||
* the header is about: the pool is one click away from the plan, and adding a
|
||||
* place you already know belongs to today should not need a second trip
|
||||
* through the day picker.
|
||||
*/
|
||||
onAddPlaceToSelectedDay?: () => void
|
||||
/**
|
||||
* Close the open day, from the note that says the pool is showing only that day.
|
||||
* Absent leaves the note without its dismiss, which is what the mobile day picker
|
||||
* wants: there the day is closed by the picker itself.
|
||||
*/
|
||||
onClearSelectedDay?: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
@@ -51,7 +66,7 @@ export interface PlacesSidebarProps {
|
||||
*/
|
||||
export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const {
|
||||
tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS,
|
||||
tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS,
|
||||
pushUndo, initialScrollTop, onScrollTopChange,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
@@ -229,10 +244,30 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
[assignments, accommodations, reservations],
|
||||
)
|
||||
|
||||
/**
|
||||
* What "planned" means while a day is open: that day's plan, not the whole trip's.
|
||||
*
|
||||
* The map has narrowed to the selected day since #2024, and the list did not, which is
|
||||
* how a trip with 55 planned places showed 55 in the pool and five pins on the map with
|
||||
* nothing to explain the gap — read, reasonably, as the map being broken. The list now
|
||||
* follows the map, and `dayScoped` tells the header to say so.
|
||||
*
|
||||
* Only "planned" narrows. "Unplanned" stays trip-wide on purpose: a place assigned to
|
||||
* some other day is planned, whichever day happens to be open.
|
||||
*/
|
||||
const plannedInDayIds = useMemo(
|
||||
() => (selectedDayId
|
||||
? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations, reservations })
|
||||
: null),
|
||||
[selectedDayId, days, assignments, accommodations, reservations],
|
||||
)
|
||||
const plannedFilterIds = plannedInDayIds ?? plannedIds
|
||||
const dayScoped = filter === 'planned' && plannedInDayIds !== null
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
@@ -245,7 +280,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
return true
|
||||
})
|
||||
return list
|
||||
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
|
||||
}, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter])
|
||||
|
||||
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
@@ -313,7 +348,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
markSelectionVisited, markVisitedBusy,
|
||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
hasTracks, plannedIds, plannedFilterIds, dayScoped, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -702,6 +702,8 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
}}
|
||||
onError={() => setLoadFailed(true)}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
allowFullScreen
|
||||
allow="fullscreen"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
title={title || pluginId}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import AnchoredPopover from './AnchoredPopover'
|
||||
|
||||
/**
|
||||
* FE-ANCHOREDPOP-001..014 — where the panel lands and what makes it go away.
|
||||
*
|
||||
* Everything here is geometry read off the page and arithmetic nobody sees until it is
|
||||
* wrong: a panel a gap below the badge, above it on a short window, pulled back inside
|
||||
* the viewport, centred on the rail rather than on the badge. The numbers are worked out
|
||||
* in the comments so a changed rule fails with a readable diff instead of a shrug.
|
||||
*
|
||||
* jsdom lays nothing out — every getBoundingClientRect comes back as zeroes, so badge,
|
||||
* rail and panel all measure the same and every branch of the placement collapses onto
|
||||
* 0,0. Sizes are therefore fed in per element, and window.innerHeight / innerWidth are
|
||||
* stated rather than left to the jsdom default, since the flip and the clamp are the
|
||||
* whole point of half these cases.
|
||||
*/
|
||||
|
||||
const rect = (top: number, left: number, width: number, height: number): DOMRect => ({
|
||||
top, left, width, height,
|
||||
bottom: top + height,
|
||||
right: left + width,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
|
||||
let section: HTMLElement
|
||||
let anchor: HTMLElement
|
||||
let panel: DOMRect
|
||||
|
||||
function viewport(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
section = document.createElement('section')
|
||||
anchor = document.createElement('button')
|
||||
section.appendChild(anchor)
|
||||
document.body.appendChild(section)
|
||||
// The rail as it stands on a desktop: a tall column at the left edge with the badge
|
||||
// hard against its far side. That offset is what the centring rule exists for.
|
||||
section.getBoundingClientRect = () => rect(100, 0, 400, 600)
|
||||
anchor.getBoundingClientRect = () => rect(120, 344, 48, 20)
|
||||
panel = rect(0, 0, 200, 150)
|
||||
viewport(1200, 800)
|
||||
// The panel is the one element that cannot be primed by hand: React makes it inside the
|
||||
// portal and the layout effect measures it in the tick it is created. So it answers off
|
||||
// the prototype, which the two own assignments above shadow. Only its size is read —
|
||||
// handing back a top and a left it does not have yet would make the test circular.
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => panel)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Both, because case 006 moves the anchor out of the section to test the fallback, so
|
||||
// it would outlive section.remove() and sit in the document for the rest of the file.
|
||||
anchor.remove()
|
||||
section.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function open() {
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
<AnchoredPopover anchor={anchor} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
return { onClose, unmount: view.unmount, rerender: view.rerender }
|
||||
}
|
||||
|
||||
const dialog = () => screen.getByRole('dialog')
|
||||
|
||||
describe('AnchoredPopover', () => {
|
||||
it('FE-ANCHOREDPOP-001: without an anchor there is nothing to hang under, and nothing listening', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<AnchoredPopover anchor={null} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
// A closed panel that keeps its key handler takes Escape away from whatever is
|
||||
// genuinely open behind it.
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-002: it hangs a gap under the badge, and is shown only once it has been placed', () => {
|
||||
open()
|
||||
|
||||
// Badge bottom 140 plus the gap. Fixed is asserted as the class rather than as a
|
||||
// computed style, because jsdom resolves no stylesheet: it is what makes every
|
||||
// number in this file a viewport coordinate instead of an offset inside a scroller.
|
||||
expect(dialog()).toHaveStyle({ top: '148px' })
|
||||
expect(dialog().className).toContain('fixed')
|
||||
expect(screen.getByRole('dialog', { name: 'Stop kind' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-015: it is portalled to the body, out of the rail that would clip it', () => {
|
||||
open()
|
||||
|
||||
// The reason this component exists at all. The rail scrolls and carries transforms,
|
||||
// and both of those trap a fixed child, so a panel rendered where it is declared
|
||||
// would be clipped by the sidebar it hangs in. Every other case here passes without
|
||||
// the portal, which is why this one asserts the parent rather than the geometry.
|
||||
expect(dialog().parentElement).toBe(document.body)
|
||||
expect(section.contains(dialog())).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-016: moving to another badge re-measures against that one', () => {
|
||||
const { onClose, rerender } = open()
|
||||
|
||||
const second = document.createElement('button')
|
||||
section.appendChild(second)
|
||||
second.getBoundingClientRect = () => rect(500, 344, 48, 20)
|
||||
rerender(
|
||||
<AnchoredPopover anchor={second} label="Stop kind" onClose={onClose}>
|
||||
<button>Inside</button>
|
||||
</AnchoredPopover>,
|
||||
)
|
||||
|
||||
// 500 plus 20 plus the gap. The rail has more than one badge worth opening and the
|
||||
// same panel serves all of them, so the anchor really does change under a mounted
|
||||
// popover. Measuring in a layout effect is what keeps that from ever being painted
|
||||
// at the previous badge: the position is committed before the browser draws.
|
||||
expect(dialog()).toHaveStyle({ top: '528px' })
|
||||
second.remove()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-003: on a short window it flips above the badge instead of off the bottom', () => {
|
||||
anchor.getBoundingClientRect = () => rect(200, 344, 48, 20)
|
||||
viewport(1200, 360)
|
||||
|
||||
open()
|
||||
|
||||
// Below would start at 228 and run to 378, past the 352 the window leaves. Above is
|
||||
// badge top 200 less the 150 it needs less the gap.
|
||||
expect(dialog()).toHaveStyle({ top: '42px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-004: a panel too tall for either side sits at the top edge rather than above it', () => {
|
||||
anchor.getBoundingClientRect = () => rect(40, 344, 48, 20)
|
||||
panel = rect(0, 0, 200, 250)
|
||||
viewport(1200, 320)
|
||||
|
||||
open()
|
||||
|
||||
// Flipping honestly would put it at -218, scrolling the half that holds the controls
|
||||
// out of reach with nothing to scroll back. Covering the badge is the lesser evil.
|
||||
expect(dialog()).toHaveStyle({ top: '8px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-005: it centres on the rail, not on the badge sitting at the edge of it', () => {
|
||||
open()
|
||||
|
||||
// Rail centre 200 less half the panel. Centred on the badge it would be 268, hanging
|
||||
// over the map and pointing at nothing.
|
||||
expect(dialog()).toHaveStyle({ left: '100px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-006: with no column around it, it falls back to the badge', () => {
|
||||
// Same badge, no rail: the panel is not owned by the sidebar and has to place itself
|
||||
// sanely wherever it is hung.
|
||||
document.body.appendChild(anchor)
|
||||
|
||||
open()
|
||||
|
||||
// Badge centre 368 less half the panel.
|
||||
expect(dialog()).toHaveStyle({ left: '268px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-007: a rail near the right edge does not push the panel off the screen', () => {
|
||||
section.getBoundingClientRect = () => rect(100, 380, 200, 600)
|
||||
viewport(500, 800)
|
||||
|
||||
open()
|
||||
|
||||
// Centred it would want 380 and end at 580, well past a window 500 wide. Clamped it
|
||||
// ends at 492, the same margin the rest of the placement keeps.
|
||||
expect(dialog()).toHaveStyle({ left: '292px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-008: a rail scrolled off the left does not push the panel off it either', () => {
|
||||
section.getBoundingClientRect = () => rect(100, -60, 100, 600)
|
||||
|
||||
open()
|
||||
|
||||
// Centred on that sliver the panel would start at -110 and lose its first half.
|
||||
expect(dialog()).toHaveStyle({ left: '8px' })
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-009: Escape closes it, and only Escape', () => {
|
||||
// The handler sits on the document, so it sees every key pressed anywhere on the
|
||||
// page, typing in a field included.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
fireEvent.keyDown(document, { key: 'e' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-010: a press anywhere else closes it', () => {
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-011: a press on what the panel contains leaves it open', () => {
|
||||
// It closes on mousedown, not on click. Without the guard a button in the panel would
|
||||
// be torn out from under the pointer before its own click ever landed.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(screen.getByText('Inside'))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-012: a press on the badge that opened it leaves it open too', () => {
|
||||
// The badge toggles. Closing here as well means one press closes the panel twice, and
|
||||
// the next press reads as the badge refusing to open.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(anchor)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-013: a scroll closes it, including one that never bubbles up', () => {
|
||||
// Scroll events do not bubble and the rail is its own scroller. A listener without
|
||||
// capture would hear nothing while the badge slides out from under a panel that stays
|
||||
// where it was measured.
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.scroll(section)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ANCHOREDPOP-014: once it is gone it has stopped listening', () => {
|
||||
// Three handlers on the document and the window, on a component that is mounted and
|
||||
// thrown away again for every badge in the rail.
|
||||
const { onClose, unmount } = open()
|
||||
|
||||
unmount()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.mouseDown(document.body)
|
||||
fireEvent.scroll(section)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/**
|
||||
* A small panel hung under something in the rail.
|
||||
*
|
||||
* The rail has more than one badge worth opening — what kind of stop this is, how full it
|
||||
* fills — and every one of them needs the same four things: measure before showing,
|
||||
* flip above when the window is short, close on an outside click or Escape, and close on
|
||||
* a scroll rather than chase the anchor. Each is one line to get wrong and none of them
|
||||
* is about what the panel contains, which is why they live here once instead of beside
|
||||
* every panel that needs them.
|
||||
*
|
||||
* Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a
|
||||
* search and a keyboard model. This is a surface.
|
||||
*/
|
||||
export default function AnchoredPopover({ anchor, label, children, onClose }: {
|
||||
/** The element the panel hangs under. Null renders nothing, which is how it stays closed. */
|
||||
anchor: HTMLElement | null
|
||||
/** What the dialog is called, for anyone who cannot see where it is hanging. */
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Measured after paint, before the browser shows it: reading the size first and then
|
||||
// placing it is what stops the panel appearing at 0,0 for one frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const p = ref.current.getBoundingClientRect()
|
||||
const gap = 8
|
||||
let top = a.bottom + gap
|
||||
// Flips above when there is no room below, which is most of the rail on a short
|
||||
// window: a panel clipped by the viewport is one that cannot be used at all.
|
||||
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
||||
// Centred on the column, not on the anchor. A badge sits hard against one edge of the
|
||||
// rail, so a panel centred on it hangs half off the sidebar and points at the map.
|
||||
// Vertically it still follows the row it belongs to.
|
||||
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
||||
const left = Math.min(
|
||||
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
||||
window.innerWidth - p.width - 8,
|
||||
)
|
||||
setPos({ top, left })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (anchor.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Any scroll moves the anchor out from under the panel, and following it would mean
|
||||
// measuring on every frame for a menu that is open for two seconds.
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!anchor) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
||||
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import RangeStrip, { blockMask } from './RangeStrip'
|
||||
|
||||
/**
|
||||
* FE-RANGESTRIP-001..015 — the picture under the range field.
|
||||
*
|
||||
* Nothing here is interactive, so what is worth pinning is the arithmetic the drawing
|
||||
* rests on: the empty state has to stay empty rather than settle on a plausible zero,
|
||||
* the block mask has a band it lives in and looks broken outside it, and the three
|
||||
* segments have to cover the bar exactly or the tail runs off the end. The chips are the
|
||||
* only readable proof that any of that happened, and the aria-label is the only route a
|
||||
* screen reader has to the figure at all.
|
||||
*/
|
||||
|
||||
const strip = (over: Partial<React.ComponentProps<typeof RangeStrip>> = {}) => (
|
||||
<RangeStrip
|
||||
rangeKm={500}
|
||||
fillPercent={null}
|
||||
wearPercent={null}
|
||||
unit={'metric' as DistanceUnit}
|
||||
electric={false}
|
||||
{...over}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* The three widths of the bar, in the order they are laid: what a stop puts in, what is
|
||||
* left of the tank, what age has taken. Read off the track rather than off the wrapper,
|
||||
* because the wrapper is the thing carrying the label.
|
||||
*/
|
||||
function segments(): number[] {
|
||||
const track = screen.getByRole('img').firstElementChild as HTMLElement
|
||||
return Array.from(track.children).map(c => parseFloat((c as HTMLElement).style.width))
|
||||
}
|
||||
|
||||
describe('RangeStrip', () => {
|
||||
it('FE-RANGESTRIP-001: with no range the figure is a dash and no unit hangs off it', () => {
|
||||
// A lone "km" beside a dash reads as a unit waiting for a number, which invites the
|
||||
// guess that the number is zero. There is no number, so there is nothing to label.
|
||||
render(strip({ rangeKm: null }))
|
||||
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
expect(screen.queryByText('km')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No range set')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Type a range/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-002: with no range the bar reads out the hint alone', () => {
|
||||
// The bar is drawn empty here, so its label is the only thing that can say why.
|
||||
render(strip({ rangeKm: null }))
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName(
|
||||
'Type a range, or fill in the figures below and TREK works it out.',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-003: the figure and its unit are split but say the same thing', () => {
|
||||
// Split so the unit can sit smaller beside the number; the split is only safe while
|
||||
// the two halves still read as one reading of one distance.
|
||||
render(strip({ rangeKm: 500 }))
|
||||
|
||||
expect(screen.getByText('500')).toBeInTheDocument()
|
||||
expect(screen.getByText('km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-004: an electric car charges where a petrol one fills', () => {
|
||||
const { rerender } = render(strip({ electric: true }))
|
||||
expect(screen.getByText('per charge')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ electric: false }))
|
||||
expect(screen.getByText('per tank')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-005: a countable range is cut into blocks and says what one is worth', () => {
|
||||
// Ten blocks. The chip is the only thing that turns the gaps from texture into a
|
||||
// measure, so it has to appear with the mask rather than merely near it.
|
||||
render(strip({ rangeKm: 500 }))
|
||||
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-006: a range too short or too long to count is left solid', () => {
|
||||
// Two blocks read as damage; forty eat the bar. Either way there is nothing to
|
||||
// count, so claiming a block is worth fifty kilometres would be claiming a mask
|
||||
// that was never cut.
|
||||
const { rerender } = render(strip({ rangeKm: 100 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 2000 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-007: the countable band has hard edges at three and thirty blocks', () => {
|
||||
// Rounded, not floored: 130 km is three blocks and 1520 km is thirty. The pairs
|
||||
// either side of each edge are what a change to that rounding would move.
|
||||
const { rerender } = render(strip({ rangeKm: 120 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 130 }))
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 1520 }))
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ rangeKm: 1560 }))
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-008: a partial fill says what that stop actually buys', () => {
|
||||
// Four fifths of a 500 km tank. The whole point of the panel is that nobody charges
|
||||
// to the top, so the note has to move off the full-tank sentence.
|
||||
render(strip({ rangeKm: 500, fillPercent: 80 }))
|
||||
|
||||
expect(screen.getByText('A 80 % stop gives 400 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText('A stop fills right up')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-009: a full, an absent and an impossible fill all read as filling right up', () => {
|
||||
// Anything outside nought to a hundred is not a fraction of a tank, and a note
|
||||
// saying a 130 % stop gives 650 km would be the panel inventing range.
|
||||
const { rerender } = render(strip({ fillPercent: 100 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: null }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: 130 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
// The other end of the same guard, and the one a cleared or mistyped field really
|
||||
// produces: a stop that puts nothing in would leave the bar empty and the note
|
||||
// claiming a range of nought.
|
||||
rerender(strip({ fillPercent: 0 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
|
||||
rerender(strip({ fillPercent: -20 }))
|
||||
expect(screen.getByText('A stop fills right up')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-010: wear is clamped to 90 rather than drawn past the bar', () => {
|
||||
// A battery reported as 150 % gone would leave the middle segment at a negative
|
||||
// width, which the browser drops: the tail would then be the whole bar and the chip
|
||||
// would be telling the traveller the car cannot move.
|
||||
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 150 }))
|
||||
|
||||
expect(screen.getByText('90 % lost to age')).toBeInTheDocument()
|
||||
expect(segments()).toEqual([8, 2, 90])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-011: with nothing lost to age there is no tail to explain', () => {
|
||||
// Zero is the ordinary case for a new car and for every petrol one. A chip reading
|
||||
// "0 % lost to age" is an answer to a question nobody asked.
|
||||
const { rerender } = render(strip({ wearPercent: 0 }))
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
|
||||
rerender(strip({ wearPercent: -20 }))
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
expect(segments()).toEqual([100, 0, 0])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-012: without a range there is no wear to take off it', () => {
|
||||
// Wear is a share of a range. With no range the share is of nothing, and a tail on
|
||||
// an empty bar would be the one filled thing on it.
|
||||
render(strip({ rangeKm: null, wearPercent: 30 }))
|
||||
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
expect(segments()).toEqual([0, 100, 0])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-013: the three segments cover the bar exactly', () => {
|
||||
// A fifth gone and a four-fifths stop: the fill is four fifths of what is LEFT, not
|
||||
// of the factory bar, or the filled part would reach into the hatched tail.
|
||||
render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 20 }))
|
||||
|
||||
expect(segments()).toEqual([64, 16, 20])
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-016: the stencil itself cuts one block per fifty kilometres', () => {
|
||||
// Asserted on the function, because jsdom implements no mask property: React writes
|
||||
// neither maskImage nor WebkitMaskImage and the element arrives with no style at
|
||||
// all, so through the DOM every one of these cases would pass on an empty bar.
|
||||
expect(blockMask(500)).toContain('repeating-linear-gradient')
|
||||
// Ten blocks of fifty, so one keep-then-cut step is a tenth of the bar.
|
||||
expect(blockMask(500)).toContain('10%')
|
||||
// Five blocks, a fifth each: the step really follows the distance rather than being
|
||||
// a fixed number of divisions dressed up as one.
|
||||
expect(blockMask(250)).toContain('20%')
|
||||
|
||||
// Below three the cuts read as damage and above thirty the gaps eat the bar, so
|
||||
// outside that the bar is honestly solid.
|
||||
expect(blockMask(100)).toBeUndefined()
|
||||
expect(blockMask(2000)).toBeUndefined()
|
||||
expect(blockMask(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-017: the wear chip carries the same hatching as the tail of the bar', () => {
|
||||
// The swatch is what ties the sentence to the stripe. Without it the hatching is an
|
||||
// unexplained texture at the end of the bar and the chip an unexplained subtraction.
|
||||
const { container } = render(strip({ rangeKm: 400, wearPercent: 12, electric: true }))
|
||||
|
||||
const hatched = Array.from(container.querySelectorAll<HTMLElement>('span[style*="repeating-linear-gradient"]'))
|
||||
expect(hatched).toHaveLength(1)
|
||||
expect(hatched[0].style.backgroundImage).toBe(
|
||||
(screen.getByRole('img').firstElementChild?.lastElementChild as HTMLElement).style.backgroundImage,
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-014: the bar reads out the figure and the note together', () => {
|
||||
// Everything else in here is colour and width. A screen reader gets the range from
|
||||
// this label or not at all, so it has to carry both halves of the answer.
|
||||
render(strip({ rangeKm: 500, fillPercent: 80 }))
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAccessibleName('500 km — A 80 % stop gives 400 km')
|
||||
})
|
||||
|
||||
it('FE-RANGESTRIP-015: imperial converts the figure, the unit and the block chip together', () => {
|
||||
// The blocks are cut in kilometres whatever the setting, so the chip is the one
|
||||
// place the two systems can drift apart: 50 km of bar has to be stated as 31.1 mi.
|
||||
render(strip({ rangeKm: 500, unit: 'imperial' as DistanceUnit }))
|
||||
|
||||
expect(screen.getByText('310.7')).toBeInTheDocument()
|
||||
expect(screen.getByText('mi')).toBeInTheDocument()
|
||||
expect(screen.getByText('One block = 31.1 mi')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* How far the car goes on one fill, drawn as the tank it leaves the factory with.
|
||||
*
|
||||
* The figure above it is the answer; this is the argument that the figure is right. It
|
||||
* carries the two things a bare number cannot say on its own:
|
||||
*
|
||||
* - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the
|
||||
* road, and a traveller who set 80 % is looking at four fifths of a bar, which is the
|
||||
* honest picture of what the next leg has.
|
||||
* - The hatched tail is what age has taken off an electric car's battery. It is already
|
||||
* out of the number above, so without the tail that figure looks like an unexplained
|
||||
* disagreement with the car's own data sheet.
|
||||
*
|
||||
* Widths animate because this sits directly under the fields that feed it: typing a
|
||||
* consumption and watching the bar move is what makes the two ways of stating a range
|
||||
* visibly the same thing.
|
||||
*
|
||||
* No box, no border, no tinted panel. It used to sit in a card of its own, which made it
|
||||
* a second surface inside a dialog whose whole point is one plain column — and the box
|
||||
* was doing nothing the bar was not already doing better.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One reading of the figure above it.
|
||||
*
|
||||
* `bg-surface-secondary` rather than the card's own colour: these sit ON the card, so the
|
||||
* chip has to be a step away from it in whichever scheme is on, and a white literal would
|
||||
* be a step the wrong way in the dark one.
|
||||
*/
|
||||
const NOTE_CHIP = 'inline-flex items-center rounded-lg bg-surface-secondary px-2 py-1 text-caption leading-snug text-content-secondary'
|
||||
|
||||
/** How many kilometres one block of the bar stands for. */
|
||||
const BLOCK_KM = 50
|
||||
|
||||
/**
|
||||
* The bar, cut into blocks of a fixed distance rather than into quarters.
|
||||
*
|
||||
* Quarters divide a bar; blocks measure a road. With one block per fifty kilometres the
|
||||
* bar for a 240 km car is visibly shorter than the bar for a 600 km one, which is the
|
||||
* comparison somebody typing a consumption is actually making — and it turns the bar into
|
||||
* something countable rather than something to eyeball.
|
||||
*
|
||||
* Cut as a MASK rather than painted over: the old quarter marks were drawn in the panel's
|
||||
* own colour to look like gaps, which tied them to that panel and left them visible as
|
||||
* stripes anywhere else. A mask makes real holes, so whatever is behind the bar shows
|
||||
* through and nothing has to know the background.
|
||||
*
|
||||
* Exported for its own test rather than checked through the bar: jsdom supports no mask
|
||||
* property at all, so React writes neither of the two and the element reaches a test
|
||||
* carrying no style whatsoever. Through the DOM the rule is unobservable.
|
||||
*/
|
||||
export function blockMask(rangeKm: number | null): string | undefined {
|
||||
if (!rangeKm) return undefined
|
||||
const blocks = Math.round(rangeKm / BLOCK_KM)
|
||||
// Below three the cuts read as damage rather than as measure, and above about thirty
|
||||
// the gaps eat the bar. Outside that the bar is simply solid, which is honest: at that
|
||||
// range the blocks were never going to be counted.
|
||||
if (blocks < 3 || blocks > 30) return undefined
|
||||
const step = 100 / blocks
|
||||
// Straight, not slanted. The slant was tried and read as a decorative texture rather
|
||||
// than as a measure: on a bar this thin the lean is legible only as fuzz on the seams.
|
||||
//
|
||||
// The colour is opacity here, not paint: black is "keep", transparent is "cut".
|
||||
return `repeating-linear-gradient(90deg, #000 0 calc(${step}% - 2px), transparent calc(${step}% - 2px) ${step}%)` // theme-lint-disable — mask stencil, not a colour
|
||||
}
|
||||
|
||||
export default function RangeStrip({ rangeKm, fillPercent, wearPercent, unit, electric }: {
|
||||
rangeKm: number | null
|
||||
fillPercent: number | null
|
||||
wearPercent: number | null
|
||||
unit: DistanceUnit
|
||||
electric: boolean
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
// Capped where the arithmetic caps it, so the picture cannot claim a battery is gone.
|
||||
const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0
|
||||
const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100
|
||||
const usable = 100 - wear
|
||||
const filled = rangeKm ? (usable * fill) / 100 : 0
|
||||
const rest = 100 - wear - filled
|
||||
// Split rather than formatted whole, so the unit can sit smaller beside the figure. The
|
||||
// rounding is formatDistance's own, because the badge on the trigger uses that and the
|
||||
// two must not disagree by a tenth.
|
||||
const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null
|
||||
const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)'
|
||||
const mask = blockMask(rangeKm)
|
||||
|
||||
const note = shown === null
|
||||
? t('roadtrip.limit.rangeEmptyHint')
|
||||
: fill < 100
|
||||
? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) })
|
||||
: t('roadtrip.limit.fullNote')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
{/* The figure and what it is a figure OF, on one baseline. Wrapping rather than
|
||||
truncating: "per charge" is three words in English and six in Hungarian. */}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="font-semibold tabular-nums leading-none text-content" style={{ fontSize: FS.total }}>
|
||||
{shown ?? '—'}
|
||||
</span>
|
||||
{shown !== null ? (
|
||||
<span className="font-semibold leading-none text-content-muted" style={{ fontSize: FS.totalUnit }}>
|
||||
{getDistanceUnitLabel(unit)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-caption text-content-faint">
|
||||
{shown === null
|
||||
? t('roadtrip.limit.rangeEmpty')
|
||||
: t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2.5 w-full overflow-hidden rounded-full bg-surface"
|
||||
role="img"
|
||||
aria-label={shown === null ? note : `${shown} ${getDistanceUnitLabel(unit)} — ${note}`}
|
||||
>
|
||||
<div className="flex h-full w-full" style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined} aria-hidden>
|
||||
<div
|
||||
className="h-full transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${filled}%`, background: 'linear-gradient(90deg, var(--accent-hover), var(--accent))' }}
|
||||
/>
|
||||
<div className="h-full transition-[width] duration-500 ease-out" style={{ width: `${rest}%` }} />
|
||||
<div
|
||||
className="h-full transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${wear}%`, backgroundImage: stripes }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three readings of this one figure — what a stop actually puts in, what one block
|
||||
on the bar stands for, what age has taken off — each on its own chip inside the
|
||||
card the number and the bar already share. As prose on one line they ran into
|
||||
each other; given the card to themselves they looked like four separate answers.
|
||||
Chips inside the card are both: three facts, one answer. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className={NOTE_CHIP}>{note}</span>
|
||||
{mask ? (
|
||||
<span className={NOTE_CHIP}>{t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })}</span>
|
||||
) : null}
|
||||
{/* The swatch is what ties the chip to the tail of the bar. Without it the
|
||||
hatching is an unexplained stripe and the sentence an unexplained loss. */}
|
||||
{wear > 0 ? (
|
||||
<span className={`${NOTE_CHIP} gap-1.5`}>
|
||||
<span className="h-2 w-3.5 shrink-0 rounded-sm" style={{ backgroundImage: stripes }} aria-hidden />
|
||||
{t('roadtrip.limit.wearNote', { percent: wear })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map.
|
||||
*
|
||||
* Every value shown comes from the same overlay the map draws. Working them out
|
||||
* here separately is what once made the list label the driven route "Fastest"
|
||||
* while offering another one as quicker beside it, because it assumed the first
|
||||
* entry was the quickest — which stopped being true the moment the road
|
||||
* currently driven was put at the top.
|
||||
*/
|
||||
|
||||
const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay => ({
|
||||
index: 0,
|
||||
coordinates: [],
|
||||
color: '#0a84ff',
|
||||
label: '3 h',
|
||||
note: '',
|
||||
duration: 10_800,
|
||||
distance: 290_000,
|
||||
slowerThanQuickest: 0,
|
||||
labelBg: '#0a84ff',
|
||||
at: { lat: 53, lng: 11 },
|
||||
...over,
|
||||
})
|
||||
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
|
||||
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
|
||||
})
|
||||
|
||||
describe('RoadtripAlternativesBar', () => {
|
||||
it('FE-ALTBAR-001: nothing open means nothing drawn', () => {
|
||||
const { container } = render(
|
||||
<RoadtripAlternativesBar open={null} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-002: while the router is answering it says so rather than showing an empty list', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/asking the router/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-003: a router that will not answer says so, and offers nothing', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ error: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/not answering/i)).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('button', { name: /km/ })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-004: one route back is not a choice, and is said as one', () => {
|
||||
// Offering a single option would ask the traveller to pick the road they are
|
||||
// already on.
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg()} overlays={[overlay()]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/only/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-005: each offer shows its distance and what it is', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, distance: 310_000, note: 'No motorway' })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Fastest')).toBeInTheDocument()
|
||||
expect(screen.getByText('No motorway')).toBeInTheDocument()
|
||||
expect(screen.getByText(/290/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/310/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-006: an offer with nothing to say about itself says how much slower it is', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, note: '', slowerThanQuickest: 1800 })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/30/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-007: choosing one reports its own index, not its position', () => {
|
||||
// The map and the list agree on `index`; a position would drift the moment
|
||||
// the driven road is prepended.
|
||||
const onChoose = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ index: 3 }), overlay({ index: 7 })]}
|
||||
onChoose={onChoose}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
expect(onChoose).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-008: pointing at an offer lights that road up, and leaving clears it', () => {
|
||||
const onHighlight = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ index: 0 }), overlay({ index: 1 })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onHighlight={onHighlight}
|
||||
/>,
|
||||
)
|
||||
const first = screen.getAllByRole('button')[1]
|
||||
fireEvent.mouseEnter(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(0)
|
||||
fireEvent.mouseLeave(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(null)
|
||||
// Keyboard too: the same road lights up on focus.
|
||||
fireEvent.focus(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(0)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-009: closing is always available, even while it is still asking', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={onClose} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react'
|
||||
import { Shuffle, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
|
||||
interface RoadtripAlternativesBarProps {
|
||||
open: LegAlternatives | null
|
||||
/** The very rows the map draws, so the two can never disagree about which is fastest. */
|
||||
overlays: AlternativeOverlay[]
|
||||
onChoose: (index: number) => void
|
||||
onClose: () => void
|
||||
/** Reports which option the pointer is on, so the map can light that road up. */
|
||||
onHighlight?: (index: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, offered over the map.
|
||||
*
|
||||
* A bar rather than a dialog: the answer is on the map behind it — which road each option
|
||||
* takes — so covering the map to ask about it would hide the only thing worth looking at.
|
||||
*
|
||||
* Every value here comes from the same overlay the map draws. Working them out separately
|
||||
* is what made the list label the driven route "Fastest" while offering another one
|
||||
* "58 min quicker" beside it: it assumed the first entry was the quickest, which stopped
|
||||
* being true the moment the road currently driven was put at the top.
|
||||
*/
|
||||
export default function RoadtripAlternativesBar({
|
||||
open, overlays, onChoose, onClose, onHighlight,
|
||||
}: RoadtripAlternativesBarProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle size={13} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.alt.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="ms-auto rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open.loading ? (
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.loading')}</p>
|
||||
) : open.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.alt.failed')}
|
||||
</p>
|
||||
) : overlays.length < 2 ? (
|
||||
// One route back means there is genuinely only one sensible way to drive it.
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{overlays.map(alt => (
|
||||
<button
|
||||
key={alt.index}
|
||||
type="button"
|
||||
onClick={() => onChoose(alt.index)}
|
||||
onMouseEnter={() => onHighlight?.(alt.index)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
onFocus={() => onHighlight?.(alt.index)}
|
||||
onBlur={() => onHighlight?.(null)}
|
||||
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the very colour this route is drawn in on the map;
|
||||
// a token here would break the one link between list and picture.
|
||||
style={{ background: alt.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-caption font-medium tabular-nums text-content">
|
||||
{formatDistance(alt.distance / 1000, distanceUnit)}
|
||||
</span>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{alt.note
|
||||
? alt.note
|
||||
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user