Compare commits

..

42 Commits

Author SHA1 Message Date
Maurice d94af63d9d test(i18n): account for the Swedish locale in SUPPORTED_LANGUAGES
The Swedish translation added 'sv' as the 21st language but left the
FE-COMP-I18N-009 length assertion at 20, so the full client suite went
red on this branch. Bump the count to 21 and add an 'sv' sample.
2026-06-26 22:36:45 +02:00
Maurice ad0944e9f1 test: make the Google Maps ftid path honest + cover the URL helper
The Places API googleMapsUri is a cid-style URL with no ftid, so the
search/getPlaceDetails fixtures had stored a fabricated ftid. Switch them
to real cid URLs and assert google_ftid is null — the precise
query_place_id link still fixes the wrong-spot bug — and document the
behaviour on googleFtidFromMapsUrl.

- add a direct googleFtidFromMapsUrl test: extracts a real /place ftid,
  returns null for a cid URL, rejects malformed/hostile values
- add placeGoogleMaps.test.ts covering the whole fallback chain
  (ftid -> place_id -> details URL -> coords) and the hostile-ftid rejection
- PlaceInspector: use a freshly-fetched ftid when the place hasn't stored one
2026-06-26 22:36:45 +02:00
Azalea fbaba1f969 Use Google Maps feature IDs for place map links 2026-06-26 22:36:45 +02:00
Azalea 9886ed732c feat(maps): add MapLibre OpenFreeMap support (#1317)
Adds MapLibre GL with OpenFreeMap as a tokenless third map provider
alongside Leaflet and Mapbox: a provider abstraction with style presets,
CSP + service-worker entries for tiles.openfreemap.org, and the
map_provider allow-list entry. Mapbox-only APIs stay gated behind the
mapbox provider, and existing Mapbox/Leaflet users are unaffected.

Maintainer review follow-ups folded in: the new map-settings strings are
translated across all locales; the GL engine is lazy-loaded so
Leaflet-only installs don't download it; MapLibre gets its own
maplibre_style slot so switching providers no longer overwrites a custom
Mapbox style; and the MapLibre render path plus the OpenFreeMap
style-guards are covered by tests.
2026-06-26 19:55:42 +02:00
Maurice 87f2ed654b i18n(sv): add the settings.distance key (Avståndsenhet)
Keeps the Swedish locale in parity with the rest after the distance-unit
setting (#1300) landed on the release branch.
2026-06-26 18:27:03 +02:00
Andreas Olsson 081405cd5e feat: swedis translation 2026-06-26 18:27:03 +02:00
Maurice 8ef9d62277 i18n(nl): fix doubled "In" typo in packing.importTitle
"InInpaklijst importeren" → "Inpaklijst importeren".
2026-06-26 18:26:58 +02:00
eindpunt 3d1074be21 Added new Dutch translations and some corrections 2026-06-26 18:26:58 +02:00
Azalea f427cb7764 feat(planner): seek places sidebar on map selection 2026-06-26 18:08:06 +02:00
Maurice b6c8a5d595 fix: address review feedback on the distance unit setting
- server: allow distance_unit as an admin default (+ value validation) so the
  Admin "Default User Settings" toggle persists instead of returning 400
- i18n: add settings.distance to all 20 locales and translate the labels
  through t() instead of hardcoding "Distance"
- route legs: include the unit in the OSRM cache key and recompute on a unit
  switch, so map and sidebar distances refresh and never mix units
- keep wind speed tied to the temperature unit — a distance setting must not
  silently flip existing Fahrenheit users from mph to km/h
- restore the sub-1km metres reading for metric, convert GPX elevation to feet
  for imperial, and format distances with a '.' decimal in every locale
- add units.test.ts
2026-06-26 17:53:36 +02:00
Matt Van Horn 33c2de5119 feat: add distance unit (metric/imperial) display setting
Mirrors the existing temperature_unit pattern. Adds distance_unit to Settings,
a Display settings control, admin default, and a formatDistance helper applied
at distance render sites. Backward compatible (default metric). Closes #1300.
2026-06-26 17:53:36 +02:00
Maurice daa4e00ab3 fix(map): drop the hotel leg to/from a transport endpoint on arrival days (#1321)
On the first day of a trip the morning hotel is only a check-in fallback
— you arrive from home, you didn't sleep there — so bookending the route
from that hotel to the flight/train departure point drew a phantom
hotel → departure leg, both on the map and in the day sidebar. The same
backwards leg showed up on a multi-day transport's arrival day, and its
mirror departure → hotel on an evening departure.

getDayBookendHotels now also reports whether the morning hotel is one you
actually slept in and whether you sleep in the evening hotel tonight. The
map and sidebar only draw a hotel↔transport bookend when that holds; a
hotel↔place leg is always kept, so the home-base loop and onward-travel
legs are unaffected. The optimizer keeps using the hotel values as before.
2026-06-26 16:32:06 +02:00
jubnl 92e3ebb4d5 chore(wiki): ensure correctness for kitinerary installation 2026-06-25 08:41:44 +02:00
jubnl 49fb2fded2 chore(wiki): make sure that all environement variables are properly documented 2026-06-24 14:03:39 +02:00
github-actions[bot] 4cd4c9c8d8 chore: bump version to 3.1.2 [skip ci] 2026-06-23 19:24:13 +00:00
jubnl 6cc8908f87 fix(tests): memory leak 2026-06-23 21:23:39 +02:00
Maurice 68f48bc070 ci: give client test workers 8 GB heap (no coverage) to fix worker OOM (#1258) 2026-06-23 21:23:39 +02:00
Maurice 76d8abb44d ci: run client tests without coverage to avoid the v8 report OOM (#1258) 2026-06-23 21:23:39 +02:00
Maurice 91c350c946 ci: raise client coverage heap to 12 GB for the v8 report phase (#1258) 2026-06-23 21:23:39 +02:00
Maurice 1e4a9a95c2 ci: raise Node heap for the client coverage run to fix OOM (#1258) 2026-06-23 21:23:39 +02:00
Maurice fe54f45d62 fix(map): draw the route line to and from the day's accommodation (#1275)
The map route ran first-activity to last-activity only, while the sidebar
already showed the hotel-to-first-stop and last-stop-to-hotel legs with
their drive times. Feed the day's accommodation bookends into the map
route too, reusing the same getDayBookendHotels lookup and the
"optimize from accommodation" gate, so the drawn line starts and ends at
the hotel, including single-activity and transfer days.
2026-06-23 21:23:39 +02:00
Maurice b36c9931b3 fix(costs): allow recording an expense with no split or payer (#1286)
Adding an expense required at least one participant, so a cost you only
want to record — e.g. a booking paid on-site later — could not be saved
without splitting it. Drop the participant requirement: with nobody
selected the expense saves as a recorded total, counted in the trip
total and shown as Unfinished, and kept out of settlements until
who-paid is filled in. The shared schema and server already supported
this case.
2026-06-23 21:23:39 +02:00
Maurice c1fe1d2d6a fix(packing): keep a custom category when its last item is removed (#1289)
Removing the only item of a user-created category deleted the whole
category. Turn that row back into the existing ... placeholder in
place instead, so the category keeps its position and colour; adding an
item reuses the placeholder slot. Deleting the placeholder (or the
category menu) still removes an empty category.
2026-06-23 21:23:39 +02:00
Maurice ebbbf91d60 fix(dashboard): show an error instead of a blank trip list when the server is unreachable (#1283)
When the backend or identity provider was unreachable, a returning user with a
persisted session landed on the dashboard with an empty trip grid and no error.
That looks identical to a logged-in user who simply has no trips, so people
assumed their data had been lost.

Three client-side layers were quietly swallowing the failure: the auth check
only cleared state on a 401, so a 5xx or a network error left the stale session
in place and kept rendering the protected route; the offline-first trip repo
turned a failed fetch into the empty cache without throwing; and the dashboard
had neither an error nor an empty state, so a blank grid meant both "outage" and
"no trips".

The auth check now tells genuine offline (keep serving the cache silently, the
PWA happy path) apart from a server outage while online (keep the session but
flag it). The dashboard shows a reassuring "couldn't reach the server, your
trips are safe" banner with a retry, and a real zero-trip account finally gets a
proper empty state so the two cases never look alike. New strings added across
all locales.
2026-06-23 21:23:39 +02:00
Maurice 328d1c9468 fix(auth): keep the last admin when OIDC claims would demote it (#1274)
On OIDC-only instances the bootstrap admin (first SSO user) rarely carries the configured admin claim, so a forced re-login — e.g. after a JWT-secret rotation — re-derived its role purely from claims and demoted it to user, locking the instance out with no recovery. The OIDC login role sync now skips a downgrade that would strip the last remaining admin, and the admin user-update endpoint guards the same case.
2026-06-23 21:23:39 +02:00
Maurice 48ebdff2d5 feat(planner): bring back the Google Maps route export button (#1255)
The day-plan route bar lost its Open in Google Maps action in the 3.1.0 redesign. A small button with the Google logo (monochrome, theme-aware) now sits next to the Route toggle and opens the day stops, in planned order, as a Google Maps directions link in a new tab.
2026-06-23 21:23:39 +02:00
Maurice 457a42b229 fix(admin): show non-Docker update steps when not running in Docker (#1269)
The "How to Update" modal always rendered Docker commands and claimed the instance runs in Docker, even on bare-metal / LXC installs like Proxmox Community Scripts. It now branches on the is_docker flag the backend already returns: non-Docker installs get a generic "re-run your install method" note plus a link to the update guide. Docker stays the default when the flag is absent, so existing installs are unaffected.
2026-06-23 21:23:39 +02:00
Alejandro Pinar Ruiz 7df5956920 feat(helm): add annotations support for PVCs (#1270)
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-23 21:23:39 +02:00
Maurice 0d50d5d7c3 fix(atlas): give the country-GeoJSON fetch a longer timeout (#1254)
The gzipped admin0 GeoJSON is still a few MB, so behind a slow reverse proxy or Cloudflare Tunnel it could exceed the global 8s axios timeout and abort, leaving the map with no countries. It now gets a 30s per-request timeout, matching the existing /maps/pois exception.
2026-06-23 21:23:39 +02:00
Maurice 4a3aa478c6 fix(dashboard): add a text-shadow so spotlight and card titles stay legible (#1267)
When no trip is ongoing the spotlight falls back to a trip gradient, and several of those are light enough that the white title vanished in light mode. A subtle text-shadow on the hero title and trip-card names keeps them readable without affecting dark covers or dark mode.
2026-06-23 21:23:39 +02:00
Maurice abee2fc088 fix(costs): move the unfinished marker to the category icon on mobile (#1266)
A long expense title pushed the "Unfinished" pill into the price on narrow screens. On mobile the status now shows as a small marker on the category icon, freeing the title and price row; desktop keeps the labelled pill.
2026-06-23 21:23:39 +02:00
Maurice e40465ba1f test(days): bump over-long-time assertion to the new 250 limit (#1252)
Follow-up to raising the day-note time cap to 250: the unit test still sent 151 chars expecting a 400, which now passes validation and fell through to an unmocked service call.
2026-06-23 21:23:39 +02:00
Maurice 8dab26fe7b fix(chart): allow setting storageClassName on PVCs (#1261)
The PVC templates rendered no storageClassName and values exposed no key, so clusters without a default StorageClass (or needing a specific class) couldn't install. Add persistence.{data,uploads}.storageClassName, omitted when empty so the default class is still used.
2026-06-23 21:23:39 +02:00
Maurice 7459067b2e fix(pdf): show photos for OSM places in the trip PDF (#1130)
The PDF photo pre-fetch only fired for places with a google_place_id, so OSM/Nominatim places (osm_id only) fell back to category icons even though they show photos in-app. Recover osm_id from the full places pool (the assignment projection drops it) and key the photo off google_place_id || osm_id || coords, matching the UI.
2026-06-23 21:23:39 +02:00
Maurice a2c552f04d fix(days): align note time limit to 250 and keep toasts above modal blur (#1252)
The day-note 'time' field capped at 150 server-side while the dialog and shared schema allow 250, so 151-250 char notes 400'd with a confusing 'time must be 150...' message. Raise the controller and MCP limits to 250. Also lift the toast container above modal overlays so the error toast isn't rendered behind the modal's backdrop blur.
2026-06-23 21:23:39 +02:00
Maurice 27762458e6 fix(dashboard): count archived trips in travel stats (#1264)
The trips/days widgets filtered out archived trips while places, countries and flight distance did not, so archiving a trip zeroed only those two. Drop the is_archived filter so all stats stay consistent.
2026-06-23 21:23:39 +02:00
Maurice adbe15abc4 fix(security): allow same-origin PDF previews under CSP (#1253)
Firefox/Chrome enforce object-src, so object-src 'none' blocked the inline <object> PDF preview (worked only in Safari). Relax to 'self' for same-origin file previews.
2026-06-23 21:23:39 +02:00
Maurice 982b99f0f6 chore: add ca_profile.xml for Unraid Community Apps submission 2026-06-23 21:23:39 +02:00
Neil Soult 6a797a39ae fix(atlas): gzip-compress responses so large country GeoJSON loads behind reverse proxies (#1262)
The admin-0 country GeoJSON served at /api/addons/atlas/countries/geo is
~30 MB uncompressed. With no compression in the request pipeline the
transfer aborts (~8s, net::ERR_FAILED despite a 200) behind reverse
proxies / Cloudflare Tunnel, so the Atlas map never colours visited
countries. LAN is unaffected.

Add the `compression` middleware to the shared applyGlobalMiddleware
pipeline (gzip brings ~30 MB down to ~4 MB). text/event-stream is
excluded so the /mcp StreamableHTTP (SSE) transport is not buffered.

Adds BOOT-008 asserting content-encoding: gzip on the geo endpoint.

Fixes #1254

Co-authored-by: pai <pai@stabpablo.eu>
2026-06-23 21:23:39 +02:00
jubnl d2cd317070 chore: dockerignore spec.ts files 2026-06-23 21:23:39 +02:00
jubnl 6ab6d79494 fix(budget): scale category bars relative to top category 2026-06-23 21:23:39 +02:00
jubnl d35972db39 fix(budget): accept comma decimal separator in expense amounts
The expense Total Amount, per-person "Who paid", and settlement amount
inputs used type="number" with a bare parseFloat. On desktop the number
input normalized comma→dot for free, but mobile keyboards drop the comma
before onChange fires, so parseFloat("39,99") silently became 39.

Switch the three inputs to type="text" inputMode="decimal" and normalize
comma→dot in their onChange handlers, matching the pattern already used
by the other budget inputs (BudgetPanelInlineEditCell, BudgetPanelAddItemRow,
DashboardPage). Both comma and dot now work on every device.

Closes #1256
2026-06-23 21:23:39 +02:00
219 changed files with 5818 additions and 495 deletions
+1
View File
@@ -32,6 +32,7 @@ server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+1 -1
View File
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- `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.
- 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.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.1
version: 3.1.2
description: Minimal Helm chart for TREK app
appVersion: "3.1.1"
appVersion: "3.1.2"
+14
View File
@@ -5,9 +5,16 @@ metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.data.size }}
@@ -18,9 +25,16 @@ metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
+5
View File
@@ -98,8 +98,13 @@ persistence:
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
annotations: {}
resources:
requests:
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.1.1",
"version": "3.1.2",
"private": true,
"type": "module",
"scripts": {
@@ -34,6 +34,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+1
View File
@@ -100,6 +100,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
}
function translateRateLimit(): string {
@@ -7,7 +7,16 @@ import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import type { Place } from '../../types'
import type { DistanceUnit, Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -19,6 +28,7 @@ const MAP_PRESETS = [
type Defaults = {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
time_format?: string
default_currency?: string
@@ -27,18 +37,22 @@ type Defaults = {
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
const MAPBOX_STYLE_PRESETS = [
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
]
type MapProvider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: MapProvider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
function OptionRow({
label,
@@ -98,10 +112,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(data.mapbox_style || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
@@ -122,7 +137,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style') setMapboxStyle('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -172,6 +190,20 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}
const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
save(patch)
}
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -212,6 +244,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Distance */}
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.distance_unit === opt.value}
onClick={() => save({ distance_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([
@@ -316,19 +364,21 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={(defaults.map_provider || 'leaflet') === opt.value}
onClick={() => save({ map_provider: opt.value })}
active={mapProvider === opt.value}
onClick={() => saveMapProvider(opt.value)}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{defaults.map_provider === 'mapbox-gl' && (
{mapProvider !== 'leaflet' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
@@ -346,17 +396,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')}
<ResetButton field="mapbox_style" />
<ResetButton field={styleKey} />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
@@ -364,12 +415,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => save({ mapbox_style: mapboxStyle })}
placeholder="mapbox://styles/mapbox/standard"
onBlur={() => {
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
setMapboxStyle(nextStyle)
save({ [styleKey]: nextStyle })
}}
placeholder={defaultStyleForProvider(mapProvider)}
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"
/>
</div>
{mapProvider === 'mapbox-gl' && (
<>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
@@ -391,6 +448,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton>
))}
</OptionRow>
</>
)}
</div>
)}
</div>
@@ -107,7 +107,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50'))
expect(nums()[2].value).toBe('50')
@@ -125,6 +125,30 @@ describe('CostsPanel — settlements in the ledger', () => {
]))
})
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
@@ -135,4 +159,39 @@ describe('CostsPanel — settlements in the ledger', () => {
await screen.findByText('Hotel')
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
// the Balances sidebar, so target the buttons specifically.
await user.click(screen.getByRole('button', { name: /alice/i }))
await user.click(screen.getByRole('button', { name: /bob/i }))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
const submit = addBtns[addBtns.length - 1] // footer submit
expect(submit).not.toBeDisabled()
await user.click(submit)
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(120)
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
})
+23 -11
View File
@@ -528,11 +528,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} />
{isMobile && isUnfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
{isUnfinished && (
{isUnfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
{t('costs.unfinished')}
@@ -632,14 +637,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function CategoryBreakdown() {
const tot: Record<string, number> = {}
let grand = 0
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => {
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -754,8 +761,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
</div>
<div>
<label className={labelCls}>{t('costs.amount')}</label>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
</div>
</div>
</Modal>
@@ -811,7 +818,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const paidEntered = paidSum > 0
const balanced = Math.abs(paidSum - totalNum) < 0.01
const each = participants.size > 0 ? totalNum / participants.size : 0
const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
// No participants = a recorded total with nobody to split with (e.g. a booking
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
// people only adds the who-owes-whom split on top.
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const splitCents = (amount: number, n: number): number[] => {
@@ -833,10 +843,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
}
const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
@@ -896,7 +908,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
onChange={e => onTotalChange(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div>
@@ -956,7 +968,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
@@ -969,7 +981,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content-faint">
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
{paidEntered
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
@@ -1,7 +1,11 @@
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
import type { JourneyMapGLHandle } from './JourneyMapGL'
// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only
// installs never download it — it ships only once a GL provider is picked.
const JourneyMapGL = lazy(() => import('./JourneyMapGL'))
// Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle
@@ -37,8 +41,9 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet — otherwise the map would just show a stub.
const useGL = provider === 'mapbox-gl' && !!token
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -47,8 +52,12 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
}), [useGL])
if (useGL) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMapGL ref={glRef} {...(props as any)} />
return (
<Suspense fallback={null}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
</Suspense>
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMap ref={leafletRef} {...(props as any)} />
+57 -35
View File
@@ -1,8 +1,11 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, type GlMapProvider } from '../Map/glProviders'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -32,6 +35,7 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
glProvider?: GlMapProvider
}
interface Item {
@@ -95,8 +99,10 @@ function ensureJourneyPopupStyle() {
const s = document.createElement('style')
s.id = 'trek-journey-popup-style'
s.textContent = `
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
.mapboxgl-popup.trek-journey-popup,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
@@ -108,20 +114,24 @@ function ensureJourneyPopupStyle() {
min-width: 160px;
max-width: 280px;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA;
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94);
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
@@ -132,7 +142,8 @@ function ensureJourneyPopupStyle() {
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub {
display: flex;
align-items: baseline;
@@ -143,7 +154,8 @@ function ensureJourneyPopupStyle() {
line-height: 1.35;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place {
min-width: 0;
overflow: hidden;
@@ -194,20 +206,28 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' },
ref
) {
const stableTrail = trail || EMPTY_TRAIL
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<string, any>>(new Map())
const itemsRef = useRef<Item[]>([])
const highlightedRef = useRef<string | null>(null)
const popupRef = useRef<mapboxgl.Popup | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark)
@@ -247,7 +267,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
} else {
popupRef.current = new mapboxgl.Popup({
popupRef.current = new gl.Popup({
closeButton: false,
closeOnClick: false,
closeOnMove: false,
@@ -260,7 +280,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
.setHTML(html)
.addTo(mapRef.current)
}
}, [])
}, [gl])
const hidePopup = useCallback(() => {
if (popupRef.current) {
@@ -305,11 +325,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: mapbox3d ? 45 : 0,
pitch: enableMapbox3d ? 45 : 0,
duration: 600,
})
} catch { /* map not yet ready */ }
}, [highlightMarker, mapbox3d])
}, [highlightMarker, enableMapbox3d])
const invalidateSize = useCallback(() => {
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
@@ -320,37 +340,39 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// Build map once per style/token change. Markers and layers are rebuilt
// inside the same effect so they stay in sync with the active style.
useEffect(() => {
if (!containerRef.current || !mapboxToken) return
mapboxgl.accessToken = mapboxToken
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
const items = buildItems(entries)
itemsRef.current = items
const bounds = new mapboxgl.LngLatBounds()
const bounds = new gl.LngLatBounds()
items.forEach(i => bounds.extend([i.lng, i.lat]))
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
const hasPoints = items.length > 0 || stableTrail.length > 0
const map = new mapboxgl.Map({
const mapOptions: Record<string, unknown> = {
container: containerRef.current,
style: mapboxStyle,
style: glStyle,
center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1,
pitch: mapbox3d && fullScreen ? 45 : 0,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
attributionControl: true,
antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator',
})
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map
map.on('load', () => {
if (mapbox3d) {
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
if (enableMapbox3d) {
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
}
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
// stay pinned to their coordinates at every zoom and pitch.
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
if (glStyle === MAPBOX_DEFAULT_STYLE) {
try { map.setTerrain(null) } catch { /* noop */ }
}
@@ -383,7 +405,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
el.addEventListener('click', (ev) => {
@@ -400,7 +422,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
map.fitBounds(bounds, {
padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16,
pitch: mapbox3d && fullScreen ? 45 : 0,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 0,
})
} catch { /* empty bounds */ }
@@ -418,7 +440,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
try { map.remove() } catch { /* noop */ }
mapRef.current = null
}
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// external activeMarkerId → highlight + flyTo
useEffect(() => {
@@ -431,15 +453,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: mapbox3d && fullScreen ? 45 : 0,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 500,
})
} catch { /* map not ready */ }
}, 50)
return () => clearTimeout(t)
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen])
if (!mapboxToken) {
if (!isMapLibre && !mapboxToken) {
return (
<div
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
+10 -4
View File
@@ -1,15 +1,21 @@
import { useEffect, useState } from 'react'
import { Navigation } from 'lucide-react'
import type mapboxgl from 'mapbox-gl'
export interface CompassMap {
getBearing: () => number
on: (type: 'rotate', listener: () => void) => unknown
off: (type: 'rotate', listener: () => void) => unknown
easeTo: (options: { bearing: number; pitch: number; duration: number }) => unknown
}
/**
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
* Round compass pill for the GL planner map. The map can be rotated and
* pitched, so this shows the current bearing (the arrow points to north) and snaps
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
export function MapCompassPill({ map }: { map: CompassMap }) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
+19 -4
View File
@@ -1,21 +1,36 @@
import { lazy, Suspense } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
import { MapViewGL } from './MapViewGL'
// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so
// Leaflet-only installs never download it — it ships only once a GL provider is picked.
const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL })))
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
//
// Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
// vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
// them online (see the GL tile rules in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token.
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
: null
if (glProvider) {
// Render the previous Leaflet map as the fallback so there's no blank flash
// while the GL chunk loads on first use.
return (
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
</Suspense>
)
}
return <MapView {...props} />
}
@@ -58,6 +58,35 @@ vi.mock('mapbox-gl', () => ({
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
},
}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
@@ -177,4 +206,25 @@ describe('MapViewGL', () => {
await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
})
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'maplibre-gl',
mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
},
} as any)
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
// The MapLibre engine builds the map even without a token; Mapbox is not used.
expect(maplibregl.Map).toHaveBeenCalled()
expect(mapboxgl.Map).not.toHaveBeenCalled()
})
})
+60 -39
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -9,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
@@ -54,7 +57,9 @@ interface Props {
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
onMapReady?: (map: mapboxgl.Map | null) => void
glProvider?: GlMapProvider
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMapReady?: (map: any | null) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -91,8 +96,8 @@ function createMarkerElement(place: Place & { category_color?: string; category_
}
const wrap = document.createElement('div')
// Do NOT set `position: relative` here — mapbox-gl ships
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
// Do NOT set `position: relative` here — GL map libraries ship
// marker classes with `position: absolute` and rely on it. An inline
// `position: relative` here overrides the class, turns every marker into
// a static block element, and stacks them in document order inside the
// canvas container. The result looks exactly like "markers drift as the
@@ -169,29 +174,39 @@ export function MapViewGL({
pois = [],
onPoiClick,
onViewportChange,
glProvider = 'mapbox-gl',
onMapReady,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, any>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poiMarkersRef = useRef<any[]>([])
// Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip.
const popupRef = useRef<mapboxgl.Popup | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
@@ -204,23 +219,25 @@ export function MapViewGL({
onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu
// Build/rebuild the map on style/token/3d change
// Build/rebuild the map on provider/style/token/3d change
useEffect(() => {
if (!containerRef.current || !mapboxToken) return
mapboxgl.accessToken = mapboxToken
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
const map = new mapboxgl.Map({
const mapOptions: Record<string, unknown> = {
container: containerRef.current,
style: mapboxStyle,
style: glStyle,
center: [center[1], center[0]],
zoom,
pitch: mapbox3d ? 45 : 0,
pitch: enableMapbox3d ? 45 : 0,
attributionControl: true,
antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator',
})
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map
popupRef.current = new mapboxgl.Popup({
popupRef.current = new gl.Popup({
closeButton: false,
closeOnClick: false,
offset: 18,
@@ -234,12 +251,12 @@ export function MapViewGL({
;(window as any).__trek_map = map
map.on('load', () => {
if (mapbox3d) {
if (enableMapbox3d) {
// Terrain is only valuable on satellite styles — on clean vector
// styles it makes route lines drift off the HTML markers because
// the lines snap to DEM height while markers stay at sea level.
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) {
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) {
const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark)
}
@@ -252,7 +269,7 @@ export function MapViewGL({
// non-satellite Standard style still looks great without terrain,
// so flatten it out to keep markers pinned. (Satellite variants
// are left alone — the DEM is what gives them their character.)
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
if (glStyle === MAPBOX_DEFAULT_STYLE) {
try { map.setTerrain(null) } catch { /* noop */ }
}
// initial route source — kept around so updates can setData() cheaply
@@ -298,7 +315,7 @@ export function MapViewGL({
map.on('click', (e) => {
const t = e.originalEvent.target as HTMLElement
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -309,7 +326,7 @@ export function MapViewGL({
}
map.on('moveend', emitViewport)
map.once('idle', emitViewport)
// In the mapbox-gl map the right mouse button is reserved for the
// In the GL map the right mouse button is reserved for the
// built-in rotate/pitch gesture, so we bind the "add place" action
// to the middle mouse button (button === 1) instead.
const canvas = map.getCanvasContainer()
@@ -356,7 +373,9 @@ export function MapViewGL({
const ll = marker.getLngLat()
let alt = 0
try {
const e = map.queryTerrainElevation([ll.lng, ll.lat])
const e = typeof map.queryTerrainElevation === 'function'
? map.queryTerrainElevation([ll.lng, ll.lat])
: null
if (typeof e === 'number' && Number.isFinite(e)) alt = e
} catch { /* terrain not ready */ }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -368,7 +387,9 @@ export function MapViewGL({
}
})
}
map.on('render', syncMarkerAltitudes)
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame
// listener entirely for MapLibre and flat mapbox styles.
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
return () => {
canvas.removeEventListener('mousedown', onAuxDown)
@@ -389,7 +410,7 @@ export function MapViewGL({
mapRef.current = null
setMapReady(false)
}
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
// simultaneous thumb arrivals into one re-render.
@@ -489,12 +510,12 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift.
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
const m = new gl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat])
.addTo(map)
markersRef.current.set(place.id, m)
})
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
@@ -511,10 +532,10 @@ export function MapViewGL({
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
}
}, [pois, mapReady])
}, [pois, mapReady, glProvider])
// Update route geojson
useEffect(() => {
@@ -578,7 +599,7 @@ export function MapViewGL({
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, gl.Marker as any)
}
reservationOverlayRef.current.update(visibleReservations, {
showConnections: true,
@@ -586,7 +607,7 @@ export function MapViewGL({
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
// Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => {
@@ -606,14 +627,14 @@ export function MapViewGL({
const target = dayPlaces.length > 0 ? dayPlaces : places
const valid = target.filter(p => p.lat && p.lng)
if (valid.length === 0) return
const bounds = new mapboxgl.LngLatBounds()
const bounds = new gl.LngLatBounds()
valid.forEach(p => bounds.extend([p.lng, p.lat]))
const run = () => {
try {
map.fitBounds(bounds, {
padding: paddingOpts,
maxZoom: 15,
pitch: mapbox3d ? 45 : 0,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
@@ -632,7 +653,7 @@ export function MapViewGL({
map.flyTo({
center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14),
pitch: mapbox3d ? 45 : 0,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather
@@ -640,7 +661,7 @@ export function MapViewGL({
padding: paddingOpts,
})
} catch { /* noop */ }
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
// External center/zoom prop changes — jump without animation
useEffect(() => {
@@ -663,7 +684,7 @@ export function MapViewGL({
}
if (!userPosition) return
const apply = () => {
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any)
locationMarkerRef.current.update(userPosition)
if (trackingMode === 'follow') {
// easeTo is gentler than flyTo for continuous updates
@@ -679,9 +700,9 @@ export function MapViewGL({
}
if (map.loaded()) apply()
else map.once('load', apply)
}, [userPosition, trackingMode])
}, [userPosition, trackingMode, glProvider])
if (!mapboxToken) {
if (!isMapLibre && !mapboxToken) {
return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
<div className="text-sm text-zinc-500">
@@ -6,6 +6,7 @@ import {
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
withHotelBookends,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -241,3 +242,46 @@ describe('generateGoogleMapsUrl', () => {
expect(result).toContain('48.86,2.36')
})
})
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
describe('withHotelBookends', () => {
const hotel = { lat: 1, lng: 1 }
const a = { lat: 2, lng: 2 }
const b = { lat: 3, lng: 3 }
const evening = { lat: 4, lng: 4 }
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
[hotel, a],
[a, b],
[b, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
[hotel, a],
[a, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
[hotel, a],
[a, b],
])
})
})
+38 -8
View File
@@ -1,4 +1,6 @@
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import { useSettingsStore } from '../../store/settingsStore'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import { formatDistance } from '../../utils/units'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -60,13 +62,34 @@ export async function calculateRoute(
coordinates,
distance,
duration,
distanceText: formatDistance(distance),
distanceText: formatRouteDistance(distance),
durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration),
}
}
/**
* Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the
* day's activity runs, so the drawn route starts and ends at the day's accommodation
* (matching the sidebar's hotel connectors). A bookend is only added when both its
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
* untouched. The shared first/last waypoint is repeated so the polylines join.
*/
export function withHotelBookends(
runs: Waypoint[][],
firstWay: Waypoint | undefined,
lastWay: Waypoint | undefined,
startHotel: Waypoint | null,
endHotel: Waypoint | null,
): Waypoint[][] {
const out: Waypoint[][] = []
if (startHotel && firstWay) out.push([startHotel, firstWay])
out.push(...runs)
if (endHotel && lastWay) out.push([lastWay, endHotel])
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length === 0) return null
@@ -197,7 +220,7 @@ export async function calculateSegments(
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance),
distanceText: formatRouteDistance(leg.distance),
}
})
}
@@ -217,7 +240,9 @@ export async function calculateRouteWithLegs(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const cacheKey = `${profile}:${coords}`
// 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).
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
@@ -244,7 +269,7 @@ export async function calculateRouteWithLegs(
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance),
distanceText: formatRouteDistance(leg.distance),
durationText: formatDuration(leg.duration),
}
}
@@ -259,11 +284,16 @@ export async function calculateRouteWithLegs(
return result
}
function formatDistance(meters: number): string {
if (meters < 1000) {
function getDistanceUnit(): DistanceUnit {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
}
function formatRouteDistance(meters: number): string {
const unit = getDistanceUnit()
if (unit === 'metric' && meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
return formatDistance(meters / 1000, unit)
}
function formatDuration(seconds: number): string {
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import {
MAPBOX_DEFAULT_STYLE,
OPENFREEMAP_DEFAULT_STYLE,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleForActiveProvider,
} from './glProviders'
describe('glProviders', () => {
it('keeps OpenFreeMap styles for MapLibre', () => {
const style = 'https://tiles.openfreemap.org/styles/bright'
expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style)
})
it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => {
expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe(
OPENFREEMAP_DEFAULT_STYLE,
)
expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
it('leaves Mapbox styles unchanged for Mapbox GL', () => {
expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('matches the OpenFreeMap CSP host', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true)
expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false)
})
it('rejects host/userinfo spoofing and http downgrade', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false)
expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true)
})
it('falls back to provider defaults for empty/whitespace styles', () => {
expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('styleForActiveProvider reads each provider\'s own style slot', () => {
const mb = 'mapbox://styles/me/custom'
const ofm = 'https://tiles.openfreemap.org/styles/bright'
expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb)
expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm)
// An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched.
expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
})
+68
View File
@@ -0,0 +1,68 @@
export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl'
export interface GlStylePreset {
name: string
url: string
tags?: string[]
}
export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard'
export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, tags: ['3D', 'Apple-like'] },
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
]
export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] },
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] },
]
export function getStylePresets(provider: GlMapProvider): GlStylePreset[] {
return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS
}
export function defaultStyleForProvider(provider: GlMapProvider): string {
return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE
}
export function isOpenFreeMapStyle(style?: string | null): boolean {
return (style || '').trim().startsWith('https://tiles.openfreemap.org/')
}
export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string {
const trimmed = (style || '').trim()
if (!trimmed) return defaultStyleForProvider(provider)
if (provider === 'maplibre-gl') {
return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE
}
return trimmed
}
/** The settings key that holds the style for a given GL provider. */
export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' {
return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
}
/**
* Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so
* switching providers never overwrites the other one's custom style. Picks and normalizes
* the style for the active provider.
*/
export function styleForActiveProvider(
provider: GlMapProvider,
mapboxStyle?: string | null,
maplibreStyle?: string | null,
): string {
return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle)
}
@@ -1,6 +1,13 @@
import mapboxgl from 'mapbox-gl'
import type mapboxgl from 'mapbox-gl'
import type { GeoPosition } from '../../hooks/useGeolocation'
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => {
setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown }
addTo: (map: mapboxgl.Map) => unknown
remove: () => void
getElement: () => HTMLElement
}
// Build the DOM element that backs the mapbox Marker. We animate the
// heading cone via a CSS rotation so the DOM stays stable across updates
// and mapbox doesn't get confused about which element to position.
@@ -66,10 +73,10 @@ export interface LocationMarkerHandle {
// mapbox map. Returns a handle the caller uses to push position updates
// and clean up. Keeps its own DOM element and GeoJSON source so it can
// coexist with the regular trip markers.
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle {
ensurePulseStyle()
const { root, cone } = buildLocationEl()
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
const marker = new MarkerCtor({ element: root, anchor: 'center' })
const ensureAccuracyLayer = () => {
if (map.getSource('trek-location-accuracy')) return
@@ -8,7 +8,7 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import type mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -220,18 +220,29 @@ export interface ReservationOverlayOptions {
onEndpointClick?: (reservationId: number) => void
}
type GlMarker = {
setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker
addTo: (map: mapboxgl.Map) => GlMarker
remove: () => void
getElement: () => HTMLElement
}
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private opts: ReservationOverlayOptions
private endpointMarkers: mapboxgl.Marker[] = []
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private MarkerCtor: MarkerConstructor
private endpointMarkers: GlMarker[] = []
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) {
this.map = map
this.opts = opts
this.MarkerCtor = MarkerCtor
this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer()
map.on('zoomend', this.rerender)
@@ -350,7 +361,7 @@ export class ReservationMapboxOverlay {
this.opts.onEndpointClick?.(item.res.id)
})
}
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
const marker = new this.MarkerCtor({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat])
.addTo(map)
this.endpointMarkers.push(marker)
+22
View File
@@ -323,6 +323,28 @@ describe('downloadTripPDF', () => {
expect(photoCalled).toBe(true)
})
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
let fetchedId: string | null = null
server.use(
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
fetchedId = params.placeId as string
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
}),
)
// The assignment projection drops osm_id; the full place in `places` carries it.
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
const args = {
...richArgs,
places: [osmPlace],
assignments: {
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
} as any,
}
await downloadTripPDF(args)
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
expect(fetchedId).toBe('node/240109189')
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = {
...minimalArgs,
+18 -10
View File
@@ -97,21 +97,29 @@ function dayCost(assignments, dayId, locale) {
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
}
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments: AssignmentsMap) {
// Pre-fetch place photos for all assigned places.
// Assignment places are a server-side projection that drops osm_id, so we recover
// the full place from the trip's places pool and key the photo off the same id the
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
// places fell back to category icons in the PDF even though they show photos in-app.
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
const photoMap = {} // placeId → photoUrl
// The assignment projection drops osm_id, so recover it from the full places pool.
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
// Assignment places are a server-side projection that omits osm_id, so photo
// pre-fetch keys off the google_place_id that the projection does carry.
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
const toFetch = unique
.map(p => ({ p, osm_id: osmById.get(p.id) }))
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
await Promise.allSettled(
toFetch.map(async (place) => {
toFetch.map(async ({ p, osm_id }) => {
// Same key the app UI uses: google_place_id || osm_id || coords.
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
try {
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
if (data.photoUrl) photoMap[p.id] = data.photoUrl
} catch {}
})
)
@@ -141,8 +149,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
// Pre-fetch place photos (Google, OSM and coords-only places)
const photoMap = await fetchPlacePhotos(assignments, places)
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
@@ -174,7 +174,9 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
let deleteCalled = false;
server.use(
http.delete('/api/trips/1/packing/99', () => {
@@ -1415,4 +1417,83 @@ describe('PackingListPanel', () => {
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
// handleDeleteItem decides "last in category" from the rendered list.
seedStore(useTripStore, { packingItems: [item] });
let deleted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.delete('/api/trips/1/packing/99', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/99', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[item]} />);
await user.click(screen.getByTitle('Delete'));
// The row is updated in place (same id) rather than deleted, so colour/position hold.
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
expect(deleted).toBe(false);
});
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let deleted = false;
let converted = false;
server.use(
http.delete('/api/trips/1/packing/5', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/5', () => {
converted = true;
return HttpResponse.json({ item: placeholder });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
await user.click(screen.getByTitle('Delete'));
await waitFor(() => expect(deleted).toBe(true));
// It is the placeholder itself — it must be removed, not re-converted.
expect(converted).toBe(false);
});
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let posted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', () => {
posted = true;
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
}),
http.put('/api/trips/1/packing/5', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
// Open the category's inline "Add item" and add a real entry.
await user.click(screen.getByText('Add item'));
const input = await screen.findByPlaceholderText('Item name...');
await user.type(input, 'Tent');
await user.keyboard('{Enter}');
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
});
@@ -18,6 +18,7 @@ interface KategorieGruppeProps {
allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void>
onDeleteItem: (item: PackingItem) => Promise<void>
onAddItem: (category: string, name: string) => Promise<void>
assignees: CategoryAssignee[]
tripMembers: TripMember[]
@@ -28,7 +29,7 @@ interface KategorieGruppeProps {
canEdit?: boolean
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -231,7 +232,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))}
{/* Inline add item */}
{canEdit && (showAddItem ? (
@@ -15,13 +15,14 @@ interface ArtikelZeileProps {
tripId: number
categories: string[]
onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<void>
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -43,6 +44,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTr
}
const handleDelete = async () => {
// The panel routes deletion through onDelete so an emptied custom category
// keeps its placeholder; fall back to a plain delete when used standalone.
if (onDelete) { await onDelete(item); return }
try { await deletePackingItem(tripId, item.id) }
catch { toast.error(t('packing.toast.deleteError')) }
}
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
export function PackingList(S: PackingState) {
const {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
} = S
@@ -31,6 +31,7 @@ export function PackingList(S: PackingState) {
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
onDeleteItem={handleDeleteItem}
onAddItem={handleAddItemToCategory}
assignees={categoryAssignees[kat] || []}
tripMembers={tripMembers}
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS } from './packingListPanel.constants'
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
export interface TripMember {
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
@@ -106,10 +106,45 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const handleAddItemToCategory = async (category: string, name: string) => {
try {
await addPackingItem(tripId, { name, category })
// Reuse the '...' placeholder slot when the category already has one, so a
// freshly-emptied category keeps its position (and therefore its colour)
// instead of the new item being appended to the end of the list.
const placeholder = useTripStore.getState().packingItems.find(
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
)
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
await addPackingItem(tripId, { name, category })
}
} catch { toast.error(t('packing.toast.addError')) }
}
// Deleting an item from a row. When it is the last item of a user-created
// category, turn that row back into the '...' placeholder in place rather than
// deleting it (#1289). Updating the row keeps its id, list position and colour,
// so the category neither disappears nor jumps to the end. The default
// (uncategorized) group and the placeholder row itself are deleted normally —
// removing the placeholder is how an empty category is dismissed.
const handleDeleteItem = async (item: PackingItem) => {
const category = item.category
const isLastInCategory = !!category
&& item.name !== PACKING_PLACEHOLDER_NAME
&& !items.some(i => i.id !== item.id && i.category === category)
try {
if (isLastInCategory) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
await updatePackingItem(tripId, item.id, {
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
})
} else {
await deletePackingItem(tripId, item.id)
}
} catch {
toast.error(t('packing.toast.deleteError'))
}
}
const handleAddNewCategory = async () => {
if (!newCatName.trim()) return
let catName = newCatName.trim()
@@ -308,7 +343,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@@ -35,6 +35,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
interface DayPlanSidebarProps {
tripId: number
@@ -154,6 +155,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
// Recompute the hotel/route legs when the user flips km↔mi so the connector
// distances refresh instead of showing stale cached text (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const legsAbortRef = useRef<AbortController | null>(null)
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
@@ -411,25 +415,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// waypoint of the day (morning) and from the last one back to it (evening). Only when
// the "optimize from accommodation" setting is on and the day has a hotel.
const day = days.find(d => d.id === selectedDayId)
const { morning: startHotel, evening: endHotel } =
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, days, accommodations)
: null
const startHotel = bookends?.morning
const endHotel = bookends?.evening
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
// legs connect even when the day starts or ends with a booking rather than a place.
const wayPts: { lat: number; lng: number }[] = []
// legs connect even when the day starts or ends with a booking rather than a place. Track
// whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1
// arrival the check-in hotel never drove to the departure airport (#1321).
const wayPts: { lat: number; lng: number; isPlace: boolean }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true })
} else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false })
if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false })
}
}
const firstWay = wayPts[0]
const lastWay = wayPts[wayPts.length - 1]
const wantTop = !!(startHotel && firstWay)
const wantBottom = !!(endHotel && lastWay)
const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere))
const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight))
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
@@ -465,7 +474,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
})()
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
@@ -1595,14 +1604,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onContextMenu={e => {
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}}
onMouseEnter={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'var(--bg-hover)'
@@ -2168,6 +2180,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
title={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
@@ -2266,4 +2300,4 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)
})
export default DayPlanSidebar
export default DayPlanSidebar
@@ -13,6 +13,7 @@ export interface PlaceFormData {
// Populated from a maps-search pick (not part of the initial blank form).
phone?: string
google_place_id?: string
google_ftid?: string
osm_id?: string
}
@@ -217,6 +217,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
address: resolved.address || prev.address,
lat: String(resolved.lat),
lng: String(resolved.lng),
google_ftid: resolved.google_ftid || prev.google_ftid,
}))
setMapsResults([])
setMapsSearch('')
@@ -241,6 +242,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: result.lat || prev.lat,
lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id,
google_ftid: result.google_ftid || prev.google_ftid,
osm_id: result.osm_id || prev.osm_id,
website: result.website || prev.website,
phone: result.phone || prev.phone,
@@ -618,6 +618,22 @@ describe('PlaceInspector', () => {
expect(mapsBtn).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
const user = userEvent.setup();
const mapsUrl = "https://www.google.com/maps/place/?q=St.%20Jacobs%20Farmers'%20Market&ftid=0x882bf179e806d471:0x8591dde29c821a93";
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
render(<PlaceInspector {...defaultProps} place={buildPlace({
name: "St. Jacobs Farmers' Market",
lat: 43.5118527,
lng: -80.5542617,
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
})} />);
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
await user.click(mapsBtn);
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
openSpy.mockRestore();
});
// ── No files section when no upload handler and no files ──────────────────
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
@@ -686,4 +702,3 @@ describe('PlaceInspector', () => {
});
});
@@ -12,6 +12,8 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { formatDistance, formatElevation } from '../../utils/units'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
const detailsCache = new Map()
@@ -122,6 +124,7 @@ export default function PlaceInspector({
const { t, locale, language } = useTranslation()
const toast = useToast()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false)
const [isUploading, setIsUploading] = useState(false)
@@ -162,6 +165,11 @@ export default function PlaceInspector({
const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
const googleMapsUrl = getGoogleMapsUrlForPlace(
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
googleDetails?.google_maps_url,
)
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
@@ -274,7 +282,8 @@ export default function PlaceInspector({
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} />
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
distanceUnit={distanceUnit} />
</div>
@@ -288,14 +297,10 @@ export default function PlaceInspector({
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
)
)}
{googleDetails?.google_maps_url && (
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
{googleMapsUrl && (
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)}
{!googleDetails?.google_maps_url && place.lat && place.lng && (
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">Google Maps</span>} />
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
@@ -682,7 +687,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
}
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: any) {
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading, distanceUnit }: any) {
return (
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
@@ -775,20 +780,20 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
{formatDistance(distKm, distanceUnit)}
</div>
{hasEle && (
<>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
{formatElevation(maxEle, distanceUnit)}
</div>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
{formatElevation(minEle, distanceUnit)}
</div>
<div className="text-content-muted" style={{ fontSize: 12 }}>
{Math.round(totalUp)} m &nbsp;{Math.round(totalDown)} m
{formatElevation(totalUp, distanceUnit)} &nbsp;{formatElevation(totalDown, distanceUnit)}
</div>
</>
)}
@@ -124,6 +124,40 @@ describe('PlacesSidebar', () => {
expect(screen.getByText('Central Park')).toBeInTheDocument();
});
it('FE-COMP-PLACES-009a: selected visible place is scrolled into view', async () => {
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
scrollIntoView.mockClear();
const places = [
buildPlace({ id: 10, name: 'First Place' }),
buildPlace({ id: 42, name: 'Map Click Target' }),
];
render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
const selectedRow = screen.getByText('Map Click Target').closest('[data-place-id="42"]');
expect(selectedRow).toHaveAttribute('aria-selected', 'true');
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
});
});
it('FE-COMP-PLACES-009b: selected place hidden by search is not scrolled', async () => {
const user = userEvent.setup();
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
const places = [
buildPlace({ id: 10, name: 'Visible Cafe' }),
buildPlace({ id: 42, name: 'Hidden Museum' }),
];
const { rerender } = render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={null} />);
await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible');
scrollIntoView.mockClear();
rerender(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
expect(screen.queryByText('Hidden Museum')).not.toBeInTheDocument();
expect(scrollIntoView).not.toHaveBeenCalled();
});
it('FE-COMP-PLACES-010: shows place count', () => {
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
render(<PlacesSidebar {...defaultProps} places={places} />);
@@ -5,7 +5,7 @@ export function PlacesList(S: SidebarState) {
const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
} = S
return (
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
@@ -44,6 +44,7 @@ export function PlacesList(S: SidebarState) {
onAssignToDay={onAssignToDay}
toggleSelected={toggleSelected}
setDayPickerPlace={setDayPickerPlace}
registerPlaceRow={registerPlaceRow}
/>
)
})
@@ -21,17 +21,21 @@ interface MemoPlaceRowProps {
onAssignToDay: (placeId: number, dayId?: number) => void
toggleSelected: (id: number) => void
setDayPickerPlace: (place: any) => void
registerPlaceRow: (placeId: number, element: HTMLDivElement | null) => void
}
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
}: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry)
return (
<div
key={place.id}
ref={element => registerPlaceRow(place.id, element)}
aria-selected={isSelected}
data-place-id={place.id}
draggable={!selectMode}
onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id))
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
const base = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, google_place_id: null, google_ftid: null } as any
describe('getGoogleMapsUrlForPlace', () => {
it('FE-PLACE-GMAPS-001: uses a valid ftid for a precise /place link', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0x47e66e2964e34e2d:0x8ddca9ee380ef7e0' })
expect(url).toBe('https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x47e66e2964e34e2d:0x8ddca9ee380ef7e0')
})
it('FE-PLACE-GMAPS-002: falls back to query_place_id when there is no ftid', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_place_id: 'ChIJ123' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
})
it('FE-PLACE-GMAPS-003: ignores a malformed/hostile ftid and falls through to the place id', () => {
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0xAB&q=evil', google_place_id: 'ChIJ123' })
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
})
it('FE-PLACE-GMAPS-004: uses the details URL when there is no ftid or place id', () => {
const url = getGoogleMapsUrlForPlace(base, 'https://maps.google.com/?cid=123')
expect(url).toBe('https://maps.google.com/?cid=123')
})
it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => {
const url = getGoogleMapsUrlForPlace(base)
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945')
})
it('FE-PLACE-GMAPS-006: returns null for no place or no location', () => {
expect(getGoogleMapsUrlForPlace(null)).toBeNull()
expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull()
})
})
@@ -0,0 +1,19 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
if (!place) return null
const ftid = place.google_ftid?.trim()
if (ftid && GOOGLE_FTID_RE.test(ftid)) {
return `https://www.google.com/maps/place/?q=${encodeURIComponent(place.name)}&ftid=${ftid}`
}
const placeId = place.google_place_id?.trim()
if (placeId) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
}
if (detailsUrl) return detailsUrl
if (place.lat == null || place.lng == null) return null
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
}
@@ -9,6 +9,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
export interface PlacesSidebarProps {
tripId: number
@@ -59,6 +60,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const placeRowRefs = useRef(new Map<number, HTMLDivElement>())
const lastAutoScrolledPlaceIdRef = useRef<number | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
@@ -197,6 +200,28 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true
}), [places, filter, categoryFilters, search, plannedIds])
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
if (element) {
placeRowRefs.current.set(placeId, element)
} else {
placeRowRefs.current.delete(placeId)
}
}, [])
useEffect(() => {
if (!props.selectedPlaceId) {
lastAutoScrolledPlaceIdRef.current = null
return
}
if (lastAutoScrolledPlaceIdRef.current === props.selectedPlaceId) return
if (!filtered.some(place => place.id === props.selectedPlaceId)) return
const selectedRow = placeRowRefs.current.get(props.selectedPlaceId)
if (!selectedRow) return
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'center' })
lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId
}, [filtered, props.selectedPlaceId])
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
@@ -210,11 +235,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
const selDayId = selectedDayIdRef.current
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
{ divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
])
@@ -234,7 +260,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
}
}
@@ -150,6 +150,22 @@ describe('DisplaySettingsTab', () => {
expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit');
});
it('FE-COMP-DISPLAY-028: metric distance button is active by default', () => {
seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } });
render(<DisplaySettingsTab />);
const metricBtn = screen.getByText('km Metric').closest('button')!;
expect(metricBtn.style.border).toContain('var(--text-primary)');
});
it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }), updateSetting });
render(<DisplaySettingsTab />);
await user.click(screen.getByText('mi Imperial'));
expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial');
});
it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
@@ -6,12 +6,14 @@ import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import Section from './Section'
import type { DistanceUnit } from '../../types'
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, updateSetting } = useSettingsStore()
const { t } = useTranslation()
const toast = useToast()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || 'metric')
const [langOpen, setLangOpen] = useState(false)
const langDropdownRef = useRef<HTMLDivElement | null>(null)
@@ -28,6 +30,10 @@ export default function DisplaySettingsTab(): React.ReactElement {
setTempUnit(settings.temperature_unit || 'celsius')
}, [settings.temperature_unit])
useEffect(() => {
setDistanceUnit(settings.distance_unit || 'metric')
}, [settings.distance_unit])
return (
<Section title={t('settings.display')} icon={Palette}>
{/* Display currency */}
@@ -200,6 +206,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
</div>
{/* Distance */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.distance')}</label>
<div className="flex gap-3">
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<button
key={opt.value}
onClick={async () => {
setDistanceUnit(opt.value)
try { await updateSetting('distance_unit', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: distanceUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: distanceUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Time Format */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
@@ -1,14 +1,22 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react'
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import MapboxPreview from './MapboxPreview'
import GlMapPreview from './MapboxPreview'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import type { Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
type GlMapProvider,
} from '../Map/glProviders'
interface MapPreset {
name: string
@@ -23,25 +31,6 @@ const MAP_PRESETS: MapPreset[] = [
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
interface StylePreset {
name: string
url: string
tags: string[]
}
const MAPBOX_STYLE_PRESETS: StylePreset[] = [
{ name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] },
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
]
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
// user scanning the list can spot 3D / Satellite / Apple-like styles.
const TAG_STYLES: Record<string, string> = {
@@ -59,6 +48,7 @@ const TAG_STYLES: Record<string, string> = {
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
}
function TagChip({ tag }: { tag: string }) {
@@ -70,10 +60,11 @@ function TagChip({ tag }: { tag: string }) {
)
}
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const presets = getStylePresets(provider)
useEffect(() => {
if (!open) return
@@ -84,7 +75,10 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value)
const selected = presets.find(p => p.url === value)
const placeholder = provider === 'maplibre-gl'
? t('settings.mapOpenFreeMapStylePlaceholder')
: t('settings.mapStylePlaceholder')
return (
<div ref={ref} className="relative">
@@ -95,11 +89,11 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
>
<span className="flex items-center gap-2 min-w-0">
<span className="text-slate-900 dark:text-white truncate">
{selected ? selected.name : t('settings.mapStylePlaceholder')}
{selected ? selected.name : placeholder}
</span>
{selected && (
<span className="flex items-center gap-1 flex-shrink-0">
{selected.tags.map(t => <TagChip key={t} tag={t} />)}
{(selected.tags || []).map(t => <TagChip key={t} tag={t} />)}
</span>
)}
</span>
@@ -107,7 +101,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
</button>
{open && (
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
{MAPBOX_STYLE_PRESETS.map(preset => {
{presets.map(preset => {
const isActive = preset.url === value
return (
<button
@@ -118,7 +112,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
>
<span className="flex items-center gap-2 flex-wrap">
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
{preset.tags.map(t => <TagChip key={t} tag={t} />)}
{(preset.tags || []).map(t => <TagChip key={t} tag={t} />)}
</span>
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
</button>
@@ -130,17 +124,34 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
)
}
type Provider = 'leaflet' | 'mapbox-gl'
type Provider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): Provider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: Provider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
// Each GL provider has its own style slot, so toggling providers never clobbers the
// other one's style. Leaflet/Mapbox use mapbox_style; MapLibre uses maplibre_style.
function slotStyle(provider: Provider, s: { mapbox_style?: string; maplibre_style?: string }): string | undefined {
return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style
}
export default function MapSettingsTab(): React.ReactElement {
const { settings, updateSettings } = useSettingsStore()
const { t } = useTranslation()
const toast = useToast()
const initialProvider = normalizeProvider(settings.map_provider)
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet')
const [provider, setProvider] = useState<Provider>(initialProvider)
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
@@ -148,10 +159,11 @@ export default function MapSettingsTab(): React.ReactElement {
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
useEffect(() => {
setProvider((settings.map_provider as Provider) || 'leaflet')
const nextProvider = normalizeProvider(settings.map_provider)
setProvider(nextProvider)
setMapTileUrl(settings.map_tile_url || '')
setMapboxToken(settings.mapbox_access_token || '')
setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
setMapbox3d(settings.mapbox_3d_enabled !== false)
setMapboxQuality(settings.mapbox_quality_mode === true)
setDefaultLat(settings.default_lat || 48.8566)
@@ -186,11 +198,15 @@ export default function MapSettingsTab(): React.ReactElement {
const saveMapSettings = async (): Promise<void> => {
setSaving(true)
try {
const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle)
setMapboxStyle(glStyle)
// Save into the active provider's own slot so the other provider's style survives.
const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle }
await updateSettings({
map_provider: provider,
map_tile_url: mapTileUrl,
mapbox_access_token: mapboxToken,
mapbox_style: mapboxStyle,
...stylePatch,
mapbox_3d_enabled: mapbox3d,
mapbox_quality_mode: mapboxQuality,
default_lat: parseFloat(String(defaultLat)),
@@ -208,16 +224,20 @@ export default function MapSettingsTab(): React.ReactElement {
// 3D is available on every style now — pure satellite uses the
// mapbox-streets-v8 tileset as a fallback building source.
const supports3d = true
const changeProvider = (nextProvider: Provider) => {
setProvider(nextProvider)
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
}
return (
<Section title={t('settings.map')} icon={Map}>
{/* Provider picker — big cards so the choice is obvious */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setProvider('leaflet')}
onClick={() => changeProvider('leaflet')}
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'leaflet'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
@@ -232,7 +252,7 @@ export default function MapSettingsTab(): React.ReactElement {
</button>
<button
type="button"
onClick={() => setProvider('mapbox-gl')}
onClick={() => changeProvider('mapbox-gl')}
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'mapbox-gl'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
@@ -252,6 +272,24 @@ export default function MapSettingsTab(): React.ReactElement {
{t('settings.mapExperimental')}
</span>
</button>
<button
type="button"
onClick={() => changeProvider('maplibre-gl')}
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'maplibre-gl'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<Globe2 size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">MapLibre</span>
<span className="hidden sm:inline">MapLibre GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
</div>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
{t('settings.mapProviderHint')}
@@ -281,9 +319,10 @@ export default function MapSettingsTab(): React.ReactElement {
</div>
)}
{/* Mapbox GL settings */}
{provider === 'mapbox-gl' && (
{/* GL settings */}
{provider !== 'leaflet' && (
<div className="space-y-3">
{provider === 'mapbox-gl' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
<input
@@ -300,24 +339,27 @@ export default function MapSettingsTab(): React.ReactElement {
</a>
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
<div className="mb-2">
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
<StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
</div>
<input
type="text"
value={mapboxStyle}
onChange={(e) => setMapboxStyle(e.target.value)}
placeholder="mapbox://styles/mapbox/standard"
placeholder={defaultStyleForProvider(provider)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">
{t('settings.mapStyleHint')}
{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
</p>
</div>
{provider === 'mapbox-gl' && (
<>
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
supports3d
? 'border-slate-200 dark:border-slate-700'
@@ -354,6 +396,8 @@ export default function MapSettingsTab(): React.ReactElement {
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
</div>
</>
)}
</div>
)}
@@ -383,8 +427,9 @@ export default function MapSettingsTab(): React.ReactElement {
<div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{provider === 'mapbox-gl' ? (
<MapboxPreview
{provider !== 'leaflet' ? (
<GlMapPreview
provider={provider}
token={mapboxToken}
style={mapboxStyle}
lat={parseFloat(String(defaultLat)) || 48.8566}
@@ -392,8 +437,8 @@ export default function MapSettingsTab(): React.ReactElement {
// Zoom in close so the style's character (3D buildings,
// satellite texture, label density) is immediately visible.
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
enable3d={mapbox3d && supports3d}
quality={mapboxQuality}
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
quality={provider === 'mapbox-gl' && mapboxQuality}
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
/>
) : (
@@ -1,10 +1,14 @@
import { useEffect, useRef } from 'react'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
interface Props {
token: string
provider?: GlMapProvider
token?: string
style: string
lat: number
lng: number
@@ -14,37 +18,44 @@ interface Props {
onClick?: (latlng: { lat: number; lng: number }) => void
}
export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
const onClickRef = useRef(onClick)
onClickRef.current = onClick
const isMapLibre = provider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = normalizeStyleForProvider(provider, style)
const enableMapbox3d = !isMapLibre && enable3d
useEffect(() => {
if (!containerRef.current || !token) return
mapboxgl.accessToken = token
if (!containerRef.current || (!isMapLibre && !token)) return
if (!isMapLibre) mapboxgl.accessToken = token
const map = new mapboxgl.Map({
const mapOptions: Record<string, unknown> = {
container: containerRef.current,
style,
style: glStyle,
center: [lng, lat],
zoom,
pitch: enable3d ? 45 : 0,
pitch: enableMapbox3d ? 45 : 0,
attributionControl: true,
antialias: quality,
projection: quality ? 'globe' : 'mercator',
})
}
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map
map.on('load', () => {
if (enable3d) {
if (!isStandardFamily(style)) addTerrainAndSky(map)
if (supportsCustom3d(style)) {
if (enableMapbox3d) {
if (!isStandardFamily(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) {
const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark)
}
}
if (style === 'mapbox://styles/mapbox/standard') {
if (glStyle === MAPBOX_DEFAULT_STYLE) {
try { map.setTerrain(null) } catch { /* noop */ }
}
})
@@ -57,7 +68,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d,
try { map.remove() } catch { /* noop */ }
mapRef.current = null
}
}, [token, style, enable3d, quality])
}, [provider, token, glStyle, enableMapbox3d, quality])
// Recenter without rebuilding the map when lat/lng/zoom change externally
useEffect(() => {
@@ -65,7 +76,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d,
try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
}, [lat, lng, zoom])
if (!token) {
if (!isMapLibre && !token) {
return (
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
Enter a Mapbox access token to preview
+3 -1
View File
@@ -102,7 +102,9 @@ export function ToastContainer() {
`}</style>
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
// blur) so error toasts paint on top and stay legible instead of blurred behind.
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}>
{toasts.map(toast => (
+53 -9
View File
@@ -1,23 +1,33 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
import { useSettingsStore } from '../store/settingsStore'
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints } from '../utils/dayMerge'
import { getDayBookendHotels } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types'
import type { RouteSegment, RouteResult, Accommodation } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
const NO_ACCOMMODATIONS: Accommodation[] = []
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
// hotel) unless the user turned the setting off — same gate as the sidebar.
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
// Recompute when the user flips km↔mi so leg distances (formatted at compute time)
// refresh instead of showing stale cached text (#1300).
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
@@ -93,10 +103,44 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
}
if (currentRun.length >= 2) runs.push(currentRun)
const straightLines = (): [number, number][][] =>
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
// Bookend the route with the day's accommodation: a hotel → first-stop run and
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
// transfer day) and already filters to accommodations that have coordinates.
const day = allDays.find(d => d.id === dayId)
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: { lat: number; lng: number }[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
}
const hotelPt = (a?: Accommodation) =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
// Only draw a hotel bookend when the leg is real. A hotel → first-stop leg holds
// if the first stop is a place, or if you actually slept in that hotel last night;
// on a day-1 arrival the morning hotel is just a check-in fallback and the first
// waypoint is the transport's departure point, so [hotel → departure] is dropped
// (#1321). Symmetrically, [last-stop → hotel] is dropped when you leave on a transport
// in the evening and don't sleep in that hotel tonight.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const drawMorning = firstStop?.kind === 'place' || !!bookends?.morningIsSleptHere
const drawEvening = lastStop?.kind === 'place' || !!bookends?.eveningIsOvernight
const runsWithHotel = withHotelBookends(
runs,
flatPts[0],
flatPts[flatPts.length - 1],
drawMorning ? hotelPt(bookends?.morning) : null,
drawEvening ? hotelPt(bookends?.evening) : null,
)
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
// OSRM road geometry.
@@ -107,7 +151,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
try {
const polylines: [number, number][][] = []
const allLegs: RouteSegment[] = []
for (const run of runs) {
for (const run of runsWithHotel) {
try {
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
@@ -123,7 +167,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
}
}, [enabled, profile])
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -147,7 +191,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+1
View File
@@ -37,6 +37,7 @@ const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: Tran
ko: () => import('@trek/shared/i18n/ko'),
uk: () => import('@trek/shared/i18n/uk'),
gr: () => import('@trek/shared/i18n/gr'),
sv: () => import('@trek/shared/i18n/sv'),
}
// Re-export pure helpers that live in shared so downstream consumers can import them
+5 -3
View File
@@ -35,17 +35,19 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
color: var(--text-primary) !important;
}
/* Mapbox GL hover popup the name/category/address card on marker hover.
/* GL hover popup the name/category/address card on marker hover.
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
onto the popup never steals the marker's mouseleave and causes flicker. */
.trek-map-popup { pointer-events: none; }
.trek-map-popup .mapboxgl-popup-content {
.trek-map-popup .mapboxgl-popup-content,
.trek-map-popup .maplibregl-popup-content {
padding: 7px 10px;
border-radius: 10px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
}
.trek-map-popup .mapboxgl-popup-tip {
.trek-map-popup .mapboxgl-popup-tip,
.trek-map-popup .maplibregl-popup-tip {
border-top-color: #fff;
border-bottom-color: #fff;
border-left-color: #fff;
+45 -2
View File
@@ -4,9 +4,10 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories';
import { buildUser, buildAdmin, buildTrip, buildSettings } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { usePermissionsStore } from '../store/permissionsStore';
import { useSettingsStore } from '../store/settingsStore';
import DashboardPage from './DashboardPage';
beforeEach(() => {
@@ -798,10 +799,51 @@ describe('DashboardPage', () => {
});
});
describe('FE-PAGE-DASH-033: Atlas distance respects distance unit setting', () => {
const distanceValue = (text: string) =>
screen.getByText((_, element) =>
element?.classList.contains('value') === true &&
element.textContent?.replace(/\s+/g, ' ').trim() === text
);
beforeEach(() => {
server.use(
http.get('/api/auth/travel-stats', () =>
HttpResponse.json({
totalTrips: 1,
totalDays: 1,
totalPlaces: 1,
totalDistanceKm: 10,
countries: [],
})
),
);
});
it('renders metric atlas distance as kilometers', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
render(<DashboardPage />);
await waitFor(() => {
expect(distanceValue('10 km')).toBeInTheDocument();
});
});
it('renders imperial atlas distance as miles', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
render(<DashboardPage />);
await waitFor(() => {
expect(distanceValue('6.2 mi')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => {
it('renders without error when dark_mode is set to auto', async () => {
// Seed settings with dark_mode = 'auto' to exercise the matchMedia branch
const { useSettingsStore } = await import('../store/settingsStore');
seedStore(useSettingsStore, {
settings: {
map_tile_url: '',
@@ -812,6 +854,7 @@ describe('DashboardPage', () => {
default_currency: 'USD',
language: 'en',
temperature_unit: 'fahrenheit',
distance_unit: 'metric',
time_format: '12h',
show_place_description: false,
blur_booking_codes: false,
+36 -3
View File
@@ -19,6 +19,7 @@ import {
LayoutGrid, List, Ticket, X,
} from 'lucide-react'
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
import { useSettingsStore } from '../store/settingsStore'
import '../styles/dashboard.css'
@@ -84,6 +85,7 @@ export default function DashboardPage(): React.ReactElement {
const {
demoMode, locale, t, navigate,
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError, retryLoad,
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
@@ -102,6 +104,15 @@ export default function DashboardPage(): React.ReactElement {
<MobileTopBar />
<main className="page">
<div className="page-main">
{loadError && (
<div className="dash-error" role="alert">
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
<button className="dash-error-retry" onClick={retryLoad}>
<RefreshCw size={15} />
{t('dashboard.retry')}
</button>
</div>
)}
{spotlight && (
<BoardingPassHero
trip={spotlight}
@@ -132,6 +143,13 @@ export default function DashboardPage(): React.ReactElement {
</div>
</div>
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
<h4>{t('dashboard.emptyTitle')}</h4>
<p>{t('dashboard.emptyText')}</p>
</div>
)}
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
{gridTrips.map(trip => (
<TripCard
@@ -341,12 +359,27 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
}
// ── Atlas / stats row ────────────────────────────────────────────────────────
function formatCompactDistance(value: number): string {
const safeValue = Number.isFinite(value) ? Math.max(0, value) : 0
// String() keeps a '.' decimal regardless of locale (no "1,5k" in non-English UIs).
if (safeValue >= 1000) {
return `${String(Math.round(safeValue / 100) / 10)}k`
}
const rounded = Math.round(safeValue * 10) / 10
if (safeValue > 0 && rounded === 0) return '<0.1'
return String(rounded)
}
function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const countries = stats?.countries || []
const distanceKm = stats?.totalDistanceKm || 0
const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm)
const equatorTimes = (distanceKm / 40075).toFixed(2)
const distance = convertDistance(distanceKm, distanceUnit)
const distanceText = formatCompactDistance(distance)
const equatorDistance = convertDistance(40075, distanceUnit)
const equatorTimes = (distance / equatorDistance).toFixed(2)
const distanceLabel = getDistanceUnitLabel(distanceUnit)
return (
<section className="atlas">
@@ -384,7 +417,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.distanceFlown')}</div>
<div className="value mono">{distanceText} <span className="unit">{t('dashboard.atlas.kmUnit')}</span></div>
<div className="value mono">{distanceText} <span className="unit">{distanceLabel}</span></div>
<div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
+2 -2
View File
@@ -5,7 +5,7 @@ import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill } from '../components/Map/MapCompassPill'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -211,7 +211,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
} = useTripPlanner()
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const [glMap, setGlMap] = useState<CompassMap | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
+18 -5
View File
@@ -229,12 +229,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
</p>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{updateInfo?.is_docker === false ? (
<a
href="https://github.com/mauriceboe/TREK/wiki/Updating"
target="_blank"
rel="noopener noreferrer"
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
</a>
) : (
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek
docker run -d --name trek \\
@@ -243,7 +255,8 @@ docker run -d --name trek \\
-v /opt/trek/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/trek:latest`}
</div>
</div>
)}
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
+5 -2
View File
@@ -134,9 +134,12 @@ export function useAtlas() {
}, [])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
// no third-party fetch from the browser).
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
// it gets a longer timeout than the global 8s default to survive slow links and
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
// with no countries (#1254).
useEffect(() => {
apiClient.get('/addons/atlas/countries/geo')
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
.then(res => {
const geo = res.data
// Dynamically build A2→A3 mapping from GeoJSON
+12 -1
View File
@@ -33,6 +33,7 @@ export function useDashboard() {
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [loadError, setLoadError] = useState<boolean>(false)
const [stats, setStats] = useState<TravelStats | null>(null)
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
@@ -42,7 +43,7 @@ export function useDashboard() {
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
const toggleViewMode = () => {
setViewMode(prev => {
@@ -74,13 +75,22 @@ export function useDashboard() {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setLoadError(false)
} catch {
setLoadError(true)
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
// Re-run both the trip fetch and the auth check so a recovered backend clears
// the error banner (loadUser resets authCheckFailed on success). #1283
const retryLoad = () => {
loadUser({ silent: true })
loadTrips()
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -177,6 +187,7 @@ export function useDashboard() {
demoMode, locale, t, navigate,
// data + derived
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError: loadError || authCheckFailed, retryLoad,
// ui state
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
@@ -289,7 +289,7 @@ export function useTripPlanner() {
})
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
const changed = dayId !== selectedDayId
+23 -5
View File
@@ -25,6 +25,11 @@ interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
/** The auth check (loadUser) failed for a non-401 reason while we were online
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
* outage doesn't render as a blank, error-free page that looks like lost data.
* Transient, never persisted. #1283 */
authCheckFailed: boolean
error: string | null
demoMode: boolean
devMode: boolean
@@ -86,6 +91,7 @@ export const useAuthStore = create<AuthState>()(
user: null,
isAuthenticated: false,
isLoading: true,
authCheckFailed: false,
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
@@ -200,6 +206,7 @@ export const useAuthStore = create<AuthState>()(
set({
user: null,
isAuthenticated: false,
authCheckFailed: false,
error: null,
})
},
@@ -215,22 +222,33 @@ export const useAuthStore = create<AuthState>()(
user: data.user,
isAuthenticated: true,
isLoading: false,
authCheckFailed: false,
})
await onAuthSuccess(data.user.id)
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
if (isAuthError) {
const status = err && typeof err === 'object' && 'response' in err
? (err as { response?: { status?: number } }).response?.status
: undefined
if (status === 401) {
// Invalid/expired token — clear auth so the guard redirects to login.
set({
user: null,
isAuthenticated: false,
isLoading: false,
authCheckFailed: false,
})
} else {
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
// Genuinely offline — keep the persisted session so the PWA serves cached
// data without a scary error. This is the offline-first happy path.
set({ isLoading: false })
} else {
// Server erroring (5xx) or unreachable while we're online: keep the session
// (don't eject the user over a transient outage), but flag it so the UI can
// say "couldn't reach the server" instead of showing a blank, error-free
// page that looks like the user's trips were lost. #1283
set({ isLoading: false, authCheckFailed: true })
}
}
},
+2
View File
@@ -30,6 +30,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
default_currency: 'USD',
language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit',
distance_unit: 'metric',
time_format: '12h',
show_place_description: false,
optimize_from_accommodation: true,
@@ -37,6 +38,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
map_poi_pill_enabled: true,
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard',
maplibre_style: '',
mapbox_3d_enabled: true,
mapbox_quality_mode: false,
},
+29 -2
View File
@@ -218,7 +218,7 @@
opacity: .88; margin-bottom: 16px; font-weight: 500;
}
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
/* ----------------- boarding pass ----------------- */
.trek-dash .hero-pass {
@@ -422,7 +422,7 @@
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
.trek-dash .trip-body { padding: 18px 20px 20px; }
@@ -456,6 +456,33 @@
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
/* Error banner shown when the trip list or the auth check couldn't reach the
server, so a backend/IdP outage no longer looks like an empty (lost-data)
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
.trek-dash .dash-error {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px 18px; margin-bottom: 22px;
background: oklch(0.74 0.14 75 / 0.13);
border: 1px solid oklch(0.74 0.14 75 / 0.45);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
}
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
.trek-dash .dash-error-retry {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 14px; border: none; border-radius: var(--r-xs);
background: var(--ink); color: var(--surface);
font-size: 13px; font-weight: 500; cursor: pointer;
transition: opacity .15s ease;
}
.trek-dash .dash-error-retry:hover { opacity: .88; }
/* Empty state a genuine "you have no trips yet" message, visually distinct
from the error banner above so an outage and a real empty list never look alike. */
.trek-dash .trips-empty { margin-bottom: 18px; }
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
/* ----------------- tools sidebar ----------------- */
.trek-dash .tool {
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
+5 -1
View File
@@ -100,6 +100,8 @@ export interface TripFile {
url: string
}
export type DistanceUnit = 'metric' | 'imperial'
export interface Settings {
map_tile_url: string
default_lat: number
@@ -109,15 +111,17 @@ export interface Settings {
default_currency: string
language: string
temperature_unit: string
distance_unit?: DistanceUnit
time_format: string
show_place_description: boolean
blur_booking_codes?: boolean
map_booking_labels?: boolean
map_poi_pill_enabled?: boolean
optimize_from_accommodation?: boolean
map_provider?: 'leaflet' | 'mapbox-gl'
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
+36
View File
@@ -117,4 +117,40 @@ describe('getDayBookendHotels', () => {
const h = hotel({ place_lat: null, place_lng: null })
expect(getDayBookendHotels(days[1], days, [h])).toEqual({})
})
it('flags an arrival/check-in day as not slept-here in the morning (#1321)', () => {
// Day 1: you arrive from home and check in tonight, so the morning hotel is only a
// check-in fallback — no hotel → departure leg should be drawn.
const into = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 3, place_lng: 4 })
const r = getDayBookendHotels(days[0], days, [into])
expect(r.morning).toBe(into)
expect(r.morningIsSleptHere).toBe(false)
expect(r.eveningIsOvernight).toBe(true)
// The optimizer anchor must stay a loop on the check-in day (values unchanged).
expect(getAccommodationAnchors(days[0], days, [into])).toEqual({ start: { lat: 3, lng: 4 }, end: { lat: 3, lng: 4 } })
})
it('flags a mid-stay day as slept-here and overnight', () => {
const h = hotel({ start_day_id: 10, end_day_id: 30 })
const r = getDayBookendHotels(days[1], days, [h])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(true)
})
it('an evening departure with no replacement check-in is not overnight (S7 mirror)', () => {
// You woke up here but check out today and board an evening transport — you do not
// sleep here tonight, so the last-stop → hotel leg must be droppable.
const h = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
const r = getDayBookendHotels(days[1], days, [h])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(false)
})
it('flags a transfer day as slept-here in the morning and overnight in the evening', () => {
const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
const r = getDayBookendHotels(days[1], days, [out, into])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(true)
})
})
+8 -1
View File
@@ -12,7 +12,7 @@ export const getDayBookendHotels = (
day: Day,
days: Day[],
accommodations: Accommodation[],
): { morning?: Accommodation; evening?: Accommodation } => {
): { morning?: Accommodation; evening?: Accommodation; morningIsSleptHere?: boolean; eveningIsOvernight?: boolean } => {
const inRange = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
@@ -30,6 +30,13 @@ export const getDayBookendHotels = (
return {
morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? inRange[0],
// Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend
// is only real when you actually used the hotel: morningIsSleptHere is true only
// when you woke up there (not a check-in fallback on an arrival day), and
// eveningIsOvernight is true only when you sleep there tonight (you check in today,
// or an earlier stay continues past today). The optimizer keeps using the values.
morningIsSleptHere: sleptHere != null,
eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd),
}
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { convertDistance, formatDistance, getDistanceUnitLabel } from './units'
describe('units', () => {
describe('getDistanceUnitLabel', () => {
it('returns km for metric and mi for imperial', () => {
expect(getDistanceUnitLabel('metric')).toBe('km')
expect(getDistanceUnitLabel('imperial')).toBe('mi')
})
})
describe('convertDistance', () => {
it('keeps kilometres for metric', () => {
expect(convertDistance(10, 'metric')).toBe(10)
})
it('converts kilometres to miles for imperial', () => {
expect(convertDistance(10, 'imperial')).toBeCloseTo(6.21371, 4)
})
it('clamps negative and non-finite input to 0', () => {
expect(convertDistance(-5, 'imperial')).toBe(0)
expect(convertDistance(NaN, 'metric')).toBe(0)
expect(convertDistance(Infinity, 'metric')).toBe(0)
})
})
describe('formatDistance', () => {
it('shows metres below 1 km for metric', () => {
expect(formatDistance(0.3, 'metric')).toBe('300 m')
expect(formatDistance(0.05, 'metric')).toBe('50 m')
})
it('shows kilometres at or above 1 km for metric', () => {
expect(formatDistance(1.5, 'metric')).toBe('1.5 km')
expect(formatDistance(10, 'metric')).toBe('10 km')
})
it('shows miles for imperial', () => {
expect(formatDistance(10, 'imperial')).toBe('6.2 mi')
})
it('shows <0.1 for a tiny imperial distance', () => {
expect(formatDistance(0.05, 'imperial')).toBe('<0.1 mi')
})
it('clamps negative and non-finite input to 0', () => {
expect(formatDistance(-1, 'metric')).toBe('0 m')
expect(formatDistance(NaN, 'imperial')).toBe('0 mi')
})
})
})
+35
View File
@@ -0,0 +1,35 @@
import type { DistanceUnit } from '../types'
const KM_TO_MI = 0.621371
const M_TO_FT = 3.28084
export function getDistanceUnitLabel(unit: DistanceUnit): 'km' | 'mi' {
return unit === 'imperial' ? 'mi' : 'km'
}
/** Formats an elevation in metres as feet for imperial, so it doesn't mix with mi distances. */
export function formatElevation(meters: number, unit: DistanceUnit): string {
const safe = Number.isFinite(meters) ? meters : 0
return unit === 'imperial' ? `${Math.round(safe * M_TO_FT)} ft` : `${Math.round(safe)} m`
}
export function convertDistance(km: number, unit: DistanceUnit): number {
const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0
return unit === 'imperial' ? safeKm * KM_TO_MI : safeKm
}
export function formatDistance(km: number, unit: DistanceUnit): string {
const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0
// Metric keeps a metres reading below 1 km (e.g. "300 m"), matching the route
// connectors; imperial has no sub-mile unit, so short hops just show "0.x mi".
if (unit === 'metric' && safeKm < 1) {
return `${Math.round(safeKm * 1000)} m`
}
const value = convertDistance(safeKm, unit)
const label = getDistanceUnitLabel(unit)
const rounded = Math.round(value * 10) / 10
// String() keeps a '.' decimal regardless of locale, matching the rest of the app
// (toFixed elsewhere) and avoiding "1,5 km" in non-English environments.
const text = value > 0 && rounded === 0 ? '<0.1' : String(rounded)
return `${text} ${label}`
}
@@ -6,13 +6,16 @@ import { buildAssignment, buildPlace } from '../../helpers/factories';
import type { TripStoreState } from '../../../src/store/tripStore';
import type { RouteSegment } from '../../../src/types';
// Mock the RouteCalculator module to avoid real OSRM fetch calls
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(),
}));
vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('../../../src/components/Map/RouteCalculator')>();
return {
...actual,
calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(),
};
});
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
@@ -248,6 +251,77 @@ describe('useRouteCalculation', () => {
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-014: #1321 day-1 arrival draws no check-in-hotel → departure leg', async () => {
// Day 1 = arrival from home: a flight (departure → arrival airport) then two activities,
// checking into a hotel tonight. The morning hotel is only a check-in fallback, so the
// hotel must NOT be bookended to the flight's departure point; the evening leg stays.
const dep = { lat: 50.03, lng: 8.57 }; // home/departure airport
const arr = { lat: 41.30, lng: 2.08 }; // destination airport
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const hotel = { lat: 41.39, lng: 2.16 };
const flight = {
id: 100, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: 0,
endpoints: [
{ role: 'from', lat: dep.lat, lng: dep.lng },
{ role: 'to', lat: arr.lat, lng: arr.lng },
],
};
const a1 = buildAssignment({ day_id: 1, order_index: 1, place: actA });
const a2 = buildAssignment({ day_id: 1, order_index: 2, place: actB });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
// A single stable store reference (like buildMockStore) so selectedDayAssignments
// keeps its identity across renders and the effect doesn't loop.
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// The spurious morning bookend [hotel → departure airport] must be gone.
expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${dep.lat},${dep.lng}`]);
// The route starts the day's run at the arrival airport, not the hotel.
expect(result.current.route?.[0]?.[0]).toEqual([arr.lat, arr.lng]);
// The evening leg [last activity → hotel] is still drawn.
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-015: day-1 with no transport keeps the hotel → first-activity leg', async () => {
// Guard against over-suppression: with no arrival transport, the check-in day is a
// home-base loop and the hotel → first-stop leg must remain.
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const hotel = { lat: 41.39, lng: 2.16 };
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: actA });
const a2 = buildAssignment({ day_id: 1, order_index: 1, place: actB });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${actA.lat},${actA.lng}`]);
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => {
const store = buildMockStore({});
const { result } = renderHook(() =>
+2 -1
View File
@@ -91,12 +91,13 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(20)
expect(SUPPORTED_LANGUAGES).toHaveLength(21)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', label: '한국어' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'sv', label: 'Svenska' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
})
})
+12
View File
@@ -63,6 +63,18 @@ export default defineConfig({
cacheableResponse: { statuses: [200] },
},
},
{
// OpenFreeMap MapLibre style, glyphs, sprites and vector tiles.
// Same best-effort offline model as Mapbox GL: viewed resources are
// reused from cache, but the vector tile pipeline is not prefetched.
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'openfreemap-tiles',
expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
{
// API calls — network only. We deliberately do NOT cache API
// responses in the Service Worker: Workbox keys entries by URL and
+333 -7
View File
@@ -1,19 +1,20 @@
{
"name": "@trek/root",
"version": "3.1.1",
"version": "3.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "3.1.1",
"version": "3.1.2",
"workspaces": [
"client",
"server",
"shared"
],
"devDependencies": {
"concurrently": "^10.0.3"
"concurrently": "^10.0.3",
"unrun": "^0.3.1"
},
"optionalDependencies": {
"@img/sharp-linuxmusl-arm64": "0.35.1",
@@ -24,7 +25,7 @@
},
"client": {
"name": "@trek/client",
"version": "3.1.1",
"version": "3.1.2",
"dependencies": {
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7",
@@ -37,6 +38,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -3918,6 +3920,119 @@
"node": ">=8"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
"integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -6603,6 +6718,17 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -6708,7 +6834,6 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
@@ -8875,6 +9000,60 @@
"node": ">= 12.0.0"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -9517,6 +9696,12 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -11033,6 +11218,12 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -12503,6 +12694,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -12580,6 +12777,12 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -13201,6 +13404,40 @@
"test/build/typings"
]
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -14464,6 +14701,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -14776,6 +15019,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -15081,6 +15333,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -15387,6 +15651,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -15636,6 +15906,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -15964,6 +16240,12 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -16511,6 +16793,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
@@ -18189,6 +18480,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
@@ -18997,6 +19294,33 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/unrun": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/unrun/-/unrun-0.3.1.tgz",
"integrity": "sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"rolldown": "^1.0.0"
},
"bin": {
"unrun": "dist/cli.mjs"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
},
"funding": {
"url": "https://github.com/sponsors/Gugustinette"
},
"peerDependencies": {
"synckit": "^0.11.11"
},
"peerDependenciesMeta": {
"synckit": {
"optional": true
}
}
},
"node_modules/until-async": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
@@ -20469,7 +20793,7 @@
},
"server": {
"name": "@trek/server",
"version": "3.1.1",
"version": "3.1.2",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"@nestjs/common": "^11.1.24",
@@ -20480,6 +20804,7 @@
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
@@ -20512,6 +20837,7 @@
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
@@ -20824,7 +21150,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "3.1.1",
"version": "3.1.2",
"dependencies": {
"isomorphic-dompurify": "^3.15.0",
"zod": "^4.3.6"
+6 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "3.1.1",
"version": "3.1.2",
"workspaces": [
"client",
"server",
@@ -25,7 +25,8 @@
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
},
"devDependencies": {
"concurrently": "^10.0.3"
"concurrently": "^10.0.3",
"unrun": "^0.3.1"
},
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
@@ -34,9 +35,9 @@
"multer": "^2.2.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.62.0",
"@rollup/rollup-linux-arm64-musl": "4.62.0",
"@img/sharp-linuxmusl-arm64": "0.35.1",
"@img/sharp-linuxmusl-x64": "0.35.1",
"@img/sharp-linuxmusl-arm64": "0.35.1"
"@rollup/rollup-linux-arm64-musl": "4.62.0",
"@rollup/rollup-linux-x64-musl": "4.62.0"
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "3.1.1",
"version": "3.1.2",
"main": "src/index.ts",
"scripts": {
"start": "node --require tsconfig-paths/register dist/index.js",
@@ -30,6 +30,7 @@
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
@@ -72,6 +73,7 @@
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
+8
View File
@@ -3054,6 +3054,14 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Store Google Maps feature IDs separately from real Google Places API IDs.
() => {
try {
db.exec('ALTER TABLE places ADD COLUMN google_ftid TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
];
if (currentVersion < migrations.length) {
+1
View File
@@ -138,6 +138,7 @@ function createTables(db: Database.Database): void {
notes TEXT,
image_url TEXT,
google_place_id TEXT,
google_ftid TEXT,
website TEXT,
phone TEXT,
transport_mode TEXT DEFAULT 'walking',
+2 -2
View File
@@ -48,7 +48,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
**Adding a place to the itinerary (correct order):**
1. \`search_place\` — find the real-world POI; note the \`osm_id\` and/or \`google_place_id\` in the result.
1. \`search_place\` — find the real-world POI; note the \`osm_id\`, \`google_place_id\`, and/or \`google_ftid\` in the result.
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
@@ -348,4 +348,4 @@ export function closeMcpSessions(): void {
}
sessions.clear();
rateLimitMap.clear();
}
}
+5 -4
View File
@@ -131,6 +131,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(),
@@ -147,7 +148,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
@@ -155,7 +156,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation };
});
@@ -230,7 +231,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
text: z.string().min(1).max(500),
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -255,7 +256,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
dayId: z.number().int().positive(),
noteId: z.number().int().positive(),
text: z.string().min(1).max(500).optional(),
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
+11 -8
View File
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool(
'create_place',
{
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, or osm_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
@@ -33,6 +33,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
@@ -42,11 +43,11 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
@@ -66,6 +67,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(),
@@ -76,14 +78,14 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
});
@@ -121,14 +123,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
@@ -196,7 +199,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (R) server.registerTool(
'search_place',
{
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.',
inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'),
},
+21 -2
View File
@@ -1,4 +1,5 @@
import express, { Request, Response, NextFunction } from 'express';
import compression from 'compression';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
@@ -28,6 +29,21 @@ export function applyGlobalMiddleware(
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
}
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
// they are excluded explicitly.
app.use(
compression({
filter: (req, res) => {
const type = res.getHeader('Content-Type');
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
return compression.filter(req, res);
},
}),
);
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null;
@@ -98,12 +114,15 @@ export function applyGlobalMiddleware(
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com",
"https://tiles.openfreemap.org"
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
objectSrc: ["'self'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
// Restrict <form> submission targets (form-action has no default-src
+4 -3
View File
@@ -17,9 +17,10 @@ import { CurrentUser } from '../auth/current-user.decorator';
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
// which runs BEFORE the trip-access check — so an over-long field 400s first.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };
// Runs BEFORE the trip-access check, so an over-long field 400s first. The `time`
// cap matches the shared dayNote schema (max 250) and the note dialog's counter;
// it was 150 here, which rejected valid 151250 char notes with a confusing error.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 250 };
function validateLengths(body: Record<string, unknown>): void {
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
+10
View File
@@ -141,6 +141,16 @@ export function updateUser(id: string, data: { username?: string; email?: string
}
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
// Don't let the admin UI demote the last remaining admin — that would leave the
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
if (role && role !== 'admin') {
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
if (current?.role === 'admin') {
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
}
}
db.prepare(`
UPDATE users SET
username = COALESCE(?, username),
+3 -2
View File
@@ -9,7 +9,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
@@ -59,6 +59,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website,
phone: a.phone,
category: a.category_id ? {
@@ -79,7 +80,7 @@ export function listDayAssignments(dayId: string | number) {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
+4 -1
View File
@@ -935,13 +935,16 @@ export function getTravelStats(userId: number) {
WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
// Archived trips still count here, matching the places, countries and flight
// distance widgets (which never filtered on is_archived) so the dashboard stats
// stay consistent — archiving a trip no longer zeroes out trips/days.
const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
WHERE (t.user_id = ? OR tm.user_id = ?)
`).get(userId, userId) as { trips: number; days: number } | undefined;
const cities = new Set<string>();
+3 -2
View File
@@ -15,7 +15,7 @@ export function getAssignmentsForDay(dayId: number | string) {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
@@ -54,6 +54,7 @@ export function getAssignmentsForDay(dayId: number | string) {
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website,
phone: a.phone,
category: a.category_id ? {
@@ -88,7 +89,7 @@ export function listDays(tripId: string | number) {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
+25 -4
View File
@@ -45,6 +45,7 @@ interface GooglePlaceResult {
websiteUri?: string;
nationalPhoneNumber?: string;
types?: string[];
googleMapsUri?: string;
}
interface GoogleAutocompleteSuggestion {
@@ -60,7 +61,6 @@ interface GoogleAutocompleteSuggestion {
interface GooglePlaceDetails extends GooglePlaceResult {
userRatingCount?: number;
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
googleMapsUri?: string;
editorialSummary?: { text: string };
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
@@ -88,6 +88,23 @@ function toApiLang(lang: string | undefined, fallback = 'en'): string {
return API_LANG_OVERRIDES[code] ?? code;
}
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
// with no ftid, so this returns null for most API responses — the precise query_place_id link is
// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
export function googleFtidFromMapsUrl(url?: string | null): string | null {
if (!url) return null;
try {
const ftid = new URL(url).searchParams.get('ftid')?.trim();
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
} catch {
return null;
}
}
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
@@ -145,6 +162,7 @@ export async function searchNominatim(query: string, lang?: string) {
const data = await response.json() as NominatimResult[];
return data.map(item => ({
google_place_id: null,
google_ftid: null,
osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '',
@@ -573,7 +591,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
},
body: JSON.stringify(searchBody),
});
@@ -588,6 +606,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
const places = (data.places || []).map((p: GooglePlaceResult) => ({
google_place_id: p.id,
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
name: p.displayName?.text || '',
address: p.formattedAddress || '',
lat: p.location?.latitude || null,
@@ -740,6 +759,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
const place = {
google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '',
address: data.formattedAddress || '',
lat: data.location?.latitude || null,
@@ -799,6 +819,7 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l
const place = {
google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '',
address: data.formattedAddress || '',
lat: data.location?.latitude || null,
@@ -983,7 +1004,7 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
let resolvedUrl = url;
// Extract coordinates from a string (URL or page body). Google Maps encodes
@@ -1064,5 +1085,5 @@ export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number;
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
return { lat, lng, name, address };
return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) };
}
+14 -2
View File
@@ -385,8 +385,20 @@ export function findOrCreateUser(
if (process.env.OIDC_ADMIN_VALUE) {
const newRole = resolveOidcRole(userInfo, false);
if (user.role !== newRole) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
// Never let the claim-based downgrade strip the last admin. The bootstrap
// admin (first SSO user) usually doesn't carry the admin claim, so a forced
// re-login — e.g. after a JWT-secret rotation — would otherwise demote it and
// lock an OIDC-only instance out for good. #1274
const demotingLastAdmin =
user.role === 'admin' &&
newRole !== 'admin' &&
(db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1;
if (demotingLastAdmin) {
console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`);
} else {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
}
}
}
return { user };
+11 -8
View File
@@ -10,8 +10,8 @@ import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
* open/closed). When the importer opts in and a Google Maps key is configured,
* we re-resolve each place by name biased to and validated against the
* imported coordinates to a real Google place, then fill in the empty fields
* and persist the resolved `google_place_id` (which is what powers on-demand
* opening hours / the proper Maps link going forward).
* and persist the resolved `google_place_id` plus `google_ftid` (which power
* on-demand opening hours and proper Maps links going forward).
*
* This runs detached from the import request (fire-and-forget) so a long list
* never blocks the response, and pushes each enriched row over the websocket so
@@ -26,6 +26,7 @@ export interface EnrichablePlace {
lat: number;
lng: number;
google_place_id?: string | null;
google_ftid?: string | null;
address?: string | null;
website?: string | null;
phone?: string | null;
@@ -105,18 +106,20 @@ async function enrichOne(tripId: string, userId: number, place: EnrichablePlace,
const gpid = str(match.google_place_id);
if (!gpid) return;
const gftid = str(match.google_ftid);
// COALESCE so enrichment only fills empty columns — never overwrites data the
// import already captured (e.g. Naver's address) or anything the user edited.
db.prepare(
`UPDATE places
SET google_place_id = COALESCE(google_place_id, ?),
address = COALESCE(address, ?),
website = COALESCE(website, ?),
phone = COALESCE(phone, ?),
updated_at = CURRENT_TIMESTAMP
SET google_place_id = COALESCE(google_place_id, ?),
google_ftid = COALESCE(google_ftid, ?),
address = COALESCE(address, ?),
website = COALESCE(website, ?),
phone = COALESCE(phone, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND trip_id = ?`,
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
).run(gpid, gftid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
// that case — a missing photo must never abort the rest of the enrichment.
+78 -12
View File
@@ -123,27 +123,27 @@ export function createPlace(
category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
transport_mode?: string; tags?: number[];
},
) {
const {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, tags = [],
} = body;
const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null,
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
);
const placeId = result.lastInsertRowid;
@@ -180,7 +180,7 @@ export function updatePlace(
category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
transport_mode?: string; tags?: number[];
},
) {
@@ -190,7 +190,7 @@ export function updatePlace(
const {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, tags,
} = body;
@@ -210,6 +210,7 @@ export function updatePlace(
notes = ?,
image_url = ?,
google_place_id = ?,
google_ftid = ?,
osm_id = ?,
website = ?,
phone = ?,
@@ -231,6 +232,7 @@ export function updatePlace(
notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
google_ftid !== undefined ? google_ftid : existingPlace.google_ftid,
osm_id !== undefined ? osm_id : existingPlace.osm_id,
website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone,
@@ -625,6 +627,65 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
// Import Google Maps list
// ---------------------------------------------------------------------------
function googleMapsHexId(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const raw = String(value).trim();
if (/^0x[0-9a-f]+$/i.test(raw)) return raw.toLowerCase();
if (!/^-?\d+$/.test(raw)) return null;
try {
const parsed = BigInt(raw);
const unsigned = parsed < 0n ? (1n << 64n) + parsed : parsed;
return `0x${unsigned.toString(16)}`;
} catch {
return null;
}
}
function googleMapsFeatureIdFromItem(item: unknown): string | null {
if (!Array.isArray(item)) return null;
const candidates = [
Array.isArray(item[1]) ? item[1][6] : null,
Array.isArray(item[7]) ? item[7][1] : null,
];
for (const ids of candidates) {
if (!Array.isArray(ids) || ids.length < 2) continue;
const first = googleMapsHexId(ids[0]);
const second = googleMapsHexId(ids[1]);
if (first && second) return `${first}:${second}`;
}
return null;
}
function findDuplicatePlace(
tripId: string,
place: { name: string | null | undefined; lat: number | null; lng: number | null },
): { id: number; google_ftid: string | null } | null {
const normalizedName = place.name?.trim().toLowerCase();
if (normalizedName) {
const duplicate = db.prepare(`
SELECT id, google_ftid FROM places
WHERE trip_id = ? AND lower(trim(name)) = ?
ORDER BY id ASC
LIMIT 1
`).get(tripId, normalizedName) as { id: number; google_ftid: string | null } | undefined;
if (duplicate) return duplicate;
}
if (place.lat != null && place.lng != null) {
return db.prepare(`
SELECT id, google_ftid FROM places
WHERE trip_id = ?
AND lat IS NOT NULL AND lng IS NOT NULL
AND abs(lat - ?) <= ?
AND abs(lng - ?) <= ?
ORDER BY id ASC
LIMIT 1
`).get(tripId, place.lat, COORD_DEDUP_TOLERANCE, place.lng, COORD_DEDUP_TOLERANCE) as { id: number; google_ftid: string | null } | undefined || null;
}
return null;
}
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
let listId: string | null = null;
let resolvedUrl = url;
@@ -689,7 +750,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
}
// Parse place data from items
const places: { name: string; lat: number; lng: number; notes: string | null }[] = [];
const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = [];
for (const item of items) {
const coords = item?.[1]?.[5];
const lat = coords?.[2];
@@ -698,7 +759,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
const note = item?.[3] || null;
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
places.push({ name, lat, lng, notes: note || null });
places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) });
}
}
@@ -708,18 +769,23 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
const dedup = buildDedupSet(tripId);
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking')
INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, 'walking')
`);
const updateGoogleFtidStmt = db.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
const created: any[] = [];
let skipped = 0;
const insertAll = db.transaction(() => {
for (const p of places) {
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
const duplicate = findDuplicatePlace(tripId, p);
if (duplicate && !duplicate.google_ftid && p.googleFtid) {
updateGoogleFtidStmt.run(p.googleFtid, duplicate.id);
}
skipped++;
continue;
}
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
+1
View File
@@ -80,6 +80,7 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website,
phone: a.phone,
category: a.category_id ? {
+6 -3
View File
@@ -8,6 +8,7 @@ const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
export const DEFAULTABLE_USER_SETTING_KEYS = [
'temperature_unit',
'distance_unit',
'dark_mode',
'time_format',
// Instance-wide default currency for Costs (new users inherit it until they
@@ -15,11 +16,12 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'default_currency',
'blur_booking_codes',
'map_tile_url',
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
// whole instance uses Mapbox without each user pasting their own key (#920).
// Instance-wide GL map defaults: admins can set Mapbox token/style or
// tokenless MapLibre/OpenFreeMap style defaults for new users (#920).
'map_provider',
'mapbox_access_token',
'mapbox_style',
'maplibre_style',
'mapbox_3d_enabled',
'mapbox_quality_mode',
] as const;
@@ -28,9 +30,10 @@ type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
temperature_unit: ['fahrenheit', 'celsius'],
distance_unit: ['metric', 'imperial'],
time_format: ['12h', '24h'],
dark_mode: [true, false, 'light', 'dark', 'auto'],
map_provider: ['leaflet', 'mapbox-gl'],
map_provider: ['leaflet', 'mapbox-gl', 'maplibre-gl'],
};
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
+3 -3
View File
@@ -632,14 +632,14 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
const insertPlace = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const p of oldPlaces) {
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
p.website, p.phone, p.transport_mode, p.osm_id);
p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id);
placeMap.set(p.id, r.lastInsertRowid);
}
+2
View File
@@ -67,6 +67,7 @@ export interface Place {
notes?: string | null;
image_url?: string | null;
google_place_id?: string | null;
google_ftid?: string | null;
osm_id?: string | null;
website?: string | null;
phone?: string | null;
@@ -323,6 +324,7 @@ export interface AssignmentRow extends DayAssignment {
image_url: string | null;
transport_mode: string;
google_place_id: string | null;
google_ftid: string | null;
website: string | null;
phone: string | null;
category_name: string | null;
@@ -122,4 +122,17 @@ describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () =>
else process.env.NODE_ENV = prev;
}
});
it('BOOT-008 — large responses are gzip-compressed (Atlas country GeoJSON, #1254)', async () => {
// The admin-0 country GeoJSON is multi-MB; without compression it stalls
// behind reverse proxies / Cloudflare Tunnel. Proves applyGlobalMiddleware
// gzips it on the wire.
const { user } = createUser(testDb);
const res = await request(instance)
.get('/api/addons/atlas/countries/geo')
.set('Accept-Encoding', 'gzip')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-encoding']).toBe('gzip');
});
});
@@ -127,8 +127,8 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
});
it('400 on an over-long time', () => {
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(151) }))).toEqual({
status: 400, body: { error: 'time must be 150 characters or less' },
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(251) }))).toEqual({
status: 400, body: { error: 'time must be 250 characters or less' },
});
});
+31 -2
View File
@@ -73,6 +73,7 @@ import {
parseOpeningHours,
buildOsmDetails,
getMapsKey,
googleFtidFromMapsUrl,
} from '../../../src/services/mapsService';
afterEach(() => {
@@ -751,13 +752,21 @@ describe('searchPlaces (fetch stubbed)', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
places: [{ id: 'gid1', displayName: { text: 'Eiffel Tower' }, formattedAddress: 'Paris', location: { latitude: 48.8, longitude: 2.3 } }],
places: [{
id: 'gid1',
displayName: { text: 'Eiffel Tower' },
formattedAddress: 'Paris',
location: { latitude: 48.8, longitude: 2.3 },
// Real search API returns a cid-style URL with no ftid → google_ftid stays null.
googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155',
}],
}),
}));
const { searchPlaces } = await import('../../../src/services/mapsService');
const result = await searchPlaces(1, 'Eiffel Tower');
expect(result.source).toBe('google');
expect((result.places[0] as any).google_place_id).toBe('gid1');
expect((result.places[0] as any).google_ftid).toBeNull();
});
it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
@@ -813,6 +822,7 @@ describe('searchPlaces (fetch stubbed)', () => {
const result = await searchPlaces(1, 'sparse');
const place = result.places[0] as any;
expect(place.google_place_id).toBe('gid-sparse');
expect(place.google_ftid).toBeNull();
expect(place.name).toBe('');
expect(place.address).toBe('');
expect(place.lat).toBeNull();
@@ -1082,7 +1092,9 @@ describe('getPlaceDetails (fetch stubbed)', () => {
weekdayDescriptions: ['Monday: 9:00 AM 12:00 AM'],
openNow: true,
},
googleMapsUri: 'https://maps.google.com/?cid=123',
// The Places API returns a cid-style URL with no ftid, so google_ftid stays null
// and the precise query_place_id link is used on the client instead.
googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155',
editorialSummary: { text: 'Iconic iron tower.' },
reviews: [
{
@@ -1099,6 +1111,7 @@ describe('getPlaceDetails (fetch stubbed)', () => {
const result = await getPlaceDetails(1, 'ChIJ123');
const place = result.place as any;
expect(place.google_place_id).toBe('ChIJ123');
expect(place.google_ftid).toBeNull();
expect(place.name).toBe('Eiffel Tower');
expect(place.rating).toBe(4.7);
expect(place.rating_count).toBe(200000);
@@ -1467,3 +1480,19 @@ describe('getPlacePhoto (fetch stubbed)', () => {
expect(mockCachePut).toHaveBeenCalledOnce();
});
});
describe('googleFtidFromMapsUrl', () => {
it('MAPS-FTID-001: extracts a valid ftid from a /place/?ftid= URL (resolved share link)', () => {
expect(googleFtidFromMapsUrl('https://www.google.com/maps/place/?q=X&ftid=0x882bf179e806d471:0x8591dde29c821a93'))
.toBe('0x882bf179e806d471:0x8591dde29c821a93');
});
it('MAPS-FTID-002: returns null for a cid-style URL (the usual Places API shape)', () => {
expect(googleFtidFromMapsUrl('https://maps.google.com/?cid=10403719659250533155')).toBeNull();
});
it('MAPS-FTID-003: rejects malformed / hostile ftid values', () => {
expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=not-an-ftid')).toBeNull();
expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=0xAB%26q%3Devil%3Cscript%3E')).toBeNull();
expect(googleFtidFromMapsUrl('not a url')).toBeNull();
expect(googleFtidFromMapsUrl(null)).toBeNull();
});
});
@@ -449,6 +449,57 @@ describe('importGoogleList', () => {
expect(result.places[1].name).toBe('London');
});
it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const listPayload = [
[null, null, null, null, 'My Test List', null, null, null, [
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
]],
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.places).toHaveLength(1);
expect(result.places[0].google_place_id).toBeNull();
expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
});
it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const existing = createPlace(testDb, trip.id, {
name: "St. Jacobs Farmers' Market",
lat: 43.5118527,
lng: -80.5542617,
}) as any;
const listPayload = [
[null, null, null, null, 'My Test List', null, null, null, [
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
]],
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any;
expect(result.places).toHaveLength(0);
expect(result.skipped).toBe(1);
expect(row.google_place_id).toBeNull();
expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
});
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -33,6 +33,7 @@ function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
image_url: 'https://example.com/img.jpg',
transport_mode: 'walk',
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
google_ftid: '0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0',
website: 'https://eiffel-tower.com',
phone: '+33 1 2345 6789',
...overrides,
@@ -66,6 +67,7 @@ describe('formatAssignmentWithPlace', () => {
expect(place.image_url).toBe('https://example.com/img.jpg');
expect(place.transport_mode).toBe('walk');
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
expect(place.google_ftid).toBe('0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0');
expect(place.website).toBe('https://eiffel-tower.com');
expect(place.phone).toBe('+33 1 2345 6789');
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "3.1.1",
"version": "3.1.2",
"private": true,
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
"type": "module",
+4
View File
@@ -284,6 +284,9 @@ const admin: TranslationStrings = {
'admin.update.backupLink': 'الذهاب إلى النسخ الاحتياطي',
'admin.update.howTo': 'كيفية التحديث',
'admin.update.dockerText': 'يعمل TREK الخاص بك في Docker. للتحديث إلى {version}، نفّذ الأوامر التالية على الخادم:',
'admin.update.nonDockerText':
'لا يعمل TREK هذا في Docker. للتحديث إلى {version}، أعد تشغيل طريقة التثبيت أو التحديث التي استخدمتها — على سبيل المثال، في Proxmox Community Scripts نفّذ التحديث من وحدة تحكم LXC:',
'admin.update.wikiLink': 'فتح دليل التحديث',
'admin.update.reloadHint': 'يرجى إعادة تحميل الصفحة بعد بضع ثوانٍ.',
'admin.tabs.permissions': 'الصلاحيات',
'admin.notifications.webhook': 'Webhook', // en-fallback
@@ -332,6 +335,7 @@ const admin: TranslationStrings = {
'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)',
'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك',
'admin.defaultSettings.mapboxTokenHint':
'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'منتهية',
'dashboard.status.daysLeft': 'متبقي {count} يوم',
'dashboard.toast.loadError': 'فشل تحميل الرحلات',
'dashboard.loadErrorBanner': 'تعذّر الوصول إلى الخادم. رحلاتك في أمان — يرجى المحاولة مرة أخرى.',
'dashboard.retry': 'إعادة المحاولة',
'dashboard.toast.created': 'تم إنشاء الرحلة بنجاح',
'dashboard.toast.createError': 'فشل إنشاء الرحلة',
'dashboard.toast.updated': 'تم تحديث الرحلة',
+4
View File
@@ -18,6 +18,7 @@ const settings: TranslationStrings = {
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية',
'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس',
'settings.mapMapLibreSubtitle': 'بلاطات متجهية من OpenFreeMap، بدون رمز',
'settings.mapExperimental': 'تجريبي',
'settings.mapMapboxToken': 'رمز وصول Mapbox',
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
@@ -25,6 +26,8 @@ const settings: TranslationStrings = {
'settings.mapStyle': 'نمط الخريطة',
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
'settings.mapOpenFreeMapStylePlaceholder': 'اختر نمط OpenFreeMap',
'settings.mapOpenFreeMapStyleHint': 'إعداد مسبق أو عنوان URL لنمط OpenFreeMap. تعمل أنماط OpenFreeMap بدون رمز.',
'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس',
'settings.map3dHint': 'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.',
'settings.mapHighQuality': 'وضع الجودة العالية',
@@ -51,6 +54,7 @@ const settings: TranslationStrings = {
'settings.auto': 'تلقائي',
'settings.language': 'اللغة',
'settings.temperature': 'وحدة الحرارة',
'settings.distance': 'وحدة المسافة',
'settings.timeFormat': 'تنسيق الوقت',
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
+4
View File
@@ -241,6 +241,9 @@ const admin: TranslationStrings = {
'admin.update.backupLink': 'Ir para Backup',
'admin.update.howTo': 'Como atualizar',
'admin.update.dockerText': 'Sua instância TREK roda no Docker. Para atualizar para {version}, execute no servidor:',
'admin.update.nonDockerText':
'Esta instância do TREK não está rodando no Docker. Para atualizar para {version}, execute novamente o método de instalação ou atualização que você usou — por exemplo, no Proxmox Community Scripts, execute a atualização a partir do console do LXC:',
'admin.update.wikiLink': 'Abrir o guia de atualização',
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
'admin.tabs.permissions': 'Permissões',
'admin.tabs.mcpTokens': 'Acesso MCP',
@@ -339,6 +342,7 @@ const admin: TranslationStrings = {
'O mapa padrão para todos nesta instância. Cada usuário ainda pode substituí-lo nas próprias configurações.',
'admin.defaultSettings.providerLeaflet': 'Padrão (gratuito)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'Token compartilhado do Mapbox',
'admin.defaultSettings.mapboxTokenHint':
'Usado para todos os usuários que não inseriram o próprio token — assim toda a instância usa o Mapbox sem compartilhar a chave individualmente. Armazenado de forma criptografada.',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Passada',
'dashboard.status.daysLeft': 'Faltam {count} dias',
'dashboard.toast.loadError': 'Não foi possível carregar as viagens',
'dashboard.loadErrorBanner': 'Não foi possível conectar ao servidor. Suas viagens estão seguras — tente novamente.',
'dashboard.retry': 'Tentar novamente',
'dashboard.toast.created': 'Viagem criada com sucesso!',
'dashboard.toast.createError': 'Não foi possível criar a viagem',
'dashboard.toast.updated': 'Viagem atualizada!',

Some files were not shown because too many files have changed in this diff Show More