Merge main into dev after the v3.1.3 release

This commit is contained in:
Maurice
2026-06-28 10:59:53 +02:00
218 changed files with 6279 additions and 620 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.1.2 version: 3.1.3
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.1.2" appVersion: "3.1.3"
+6
View File
@@ -70,3 +70,9 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }} {{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }} MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }} {{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
+6
View File
@@ -67,6 +67,12 @@ env:
# Max MCP API requests per user per minute. Defaults to 300. # Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20" # MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20. # Max concurrent MCP sessions per user. Defaults to 20.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# Secret environment variables stored in a Kubernetes Secret. # Secret environment variables stored in a Kubernetes Secret.
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" /> <link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/client", "name": "@trek/client",
"version": "3.1.2", "version": "3.1.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -34,6 +34,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -81,7 +82,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^8.0.16", "vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
+1
View File
@@ -100,6 +100,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ja: '試行回数が多すぎます。時間をおいて再度お試しください。', ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.', ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.', uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
} }
function translateRateLimit(): string { function translateRateLimit(): string {
@@ -7,7 +7,16 @@ import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView' import { MapView } from '../Map/MapView'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' 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 = [ const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -19,6 +28,7 @@ const MAP_PRESETS = [
type Defaults = { type Defaults = {
temperature_unit?: string temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean dark_mode?: string | boolean
time_format?: string time_format?: string
default_currency?: string default_currency?: string
@@ -27,18 +37,22 @@ type Defaults = {
map_provider?: string map_provider?: string
mapbox_access_token?: string mapbox_access_token?: string
mapbox_style?: string mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean mapbox_quality_mode?: boolean
} }
const MAPBOX_STYLE_PRESETS = [ type MapProvider = 'leaflet' | GlMapProvider
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' }, function normalizeProvider(value: unknown): MapProvider {
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' }, return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
{ 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' }, 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({ function OptionRow({
label, label,
@@ -98,10 +112,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
useEffect(() => { useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => { adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data) setDefaults(data)
setMapTileUrl(data.map_tile_url || '') setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '') 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) setLoaded(true)
}).catch(() => setLoaded(true)) }).catch(() => setLoaded(true))
}, []) }, [])
@@ -122,7 +137,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated) setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('') if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('') 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')) toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error')) 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 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 ( return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}> <Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -212,6 +244,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))} ))}
</OptionRow> </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 */} {/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="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: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') }, { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
] as const).map(opt => ( ] as const).map(opt => (
<OptionButton <OptionButton
key={opt.value} key={opt.value}
active={(defaults.map_provider || 'leaflet') === opt.value} active={mapProvider === opt.value}
onClick={() => save({ map_provider: opt.value })} onClick={() => saveMapProvider(opt.value)}
> >
{opt.label} {opt.label}
</OptionButton> </OptionButton>
))} ))}
</OptionRow> </OptionRow>
{defaults.map_provider === 'mapbox-gl' && ( {mapProvider !== 'leaflet' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}> <div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary"> <label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')} {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> <p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div> </div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary"> <label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')} {t('admin.defaultSettings.mapboxStyle')}
<ResetButton field="mapbox_style" /> <ResetButton field={styleKey} />
</label> </label>
<CustomSelect <CustomSelect
value={mapboxStyle} 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')} 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" size="sm"
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
/> />
@@ -364,12 +415,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
type="text" type="text"
value={mapboxStyle} value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => save({ mapbox_style: mapboxStyle })} onBlur={() => {
placeholder="mapbox://styles/mapbox/standard" 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" 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> </div>
{mapProvider === 'mapbox-gl' && (
<>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}> <OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([ {([
{ value: true, label: t('settings.on') || 'On' }, { value: true, label: t('settings.on') || 'On' },
@@ -391,6 +448,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton> </OptionButton>
))} ))}
</OptionRow> </OptionRow>
</>
)}
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,11 @@
import { forwardRef, useImperativeHandle, useRef } from 'react' import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import JourneyMap, { type JourneyMapHandle } from './JourneyMap' 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. // Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle export type JourneyMapAutoHandle = JourneyMapHandle
@@ -37,8 +41,9 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
const glRef = useRef<JourneyMapGLHandle>(null) const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't // 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. // supplied a token yet. MapLibre/OpenFreeMap is tokenless.
const useGL = provider === 'mapbox-gl' && !!token const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id), highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -47,8 +52,12 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
}), [useGL]) }), [useGL])
if (useGL) { if (useGL) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any return (
return <JourneyMapGL ref={glRef} {...(props as any)} /> <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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMap ref={leafletRef} {...(props as any)} /> return <JourneyMap ref={leafletRef} {...(props as any)} />
+63 -35
View File
@@ -1,8 +1,11 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
export interface JourneyMapGLHandle { export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void highlightMarker: (id: string | null) => void
@@ -32,6 +35,7 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean fullScreen?: boolean
paddingBottom?: number paddingBottom?: number
glProvider?: GlMapProvider
} }
interface Item { interface Item {
@@ -95,8 +99,10 @@ function ensureJourneyPopupStyle() {
const s = document.createElement('style') const s = document.createElement('style')
s.id = 'trek-journey-popup-style' s.id = 'trek-journey-popup-style'
s.textContent = ` 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.trek-journey-popup .mapboxgl-popup-content { .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; padding: 9px 14px 10px;
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
@@ -108,20 +114,24 @@ function ensureJourneyPopupStyle() {
min-width: 160px; min-width: 160px;
max-width: 280px; 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); background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA; 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-top-color: rgba(255, 255, 255, 0.94);
border-bottom-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-top-color: rgba(24, 24, 27, 0.88);
border-bottom-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 { .trek-journey-popup-title {
font-size: 13.5px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
@@ -132,7 +142,8 @@ function ensureJourneyPopupStyle() {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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 { .trek-journey-popup-sub {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -143,7 +154,8 @@ function ensureJourneyPopupStyle() {
line-height: 1.35; line-height: 1.35;
white-space: nowrap; 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 { .trek-journey-popup-place {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -194,20 +206,29 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
const EMPTY_TRAIL: { lat: number; lng: number }[] = [] const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL( 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 ref
) { ) {
const stableTrail = trail || EMPTY_TRAIL 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 mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const mapLang = useSettingsStore(s => s.settings.language)
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 containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map()) 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 itemsRef = useRef<Item[]>([])
const highlightedRef = useRef<string | null>(null) 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) const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark) const darkRef = useRef(dark)
@@ -247,7 +268,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const el = popupRef.current.getElement() const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current) if (el) el.classList.toggle('trek-dark', !!darkRef.current)
} else { } else {
popupRef.current = new mapboxgl.Popup({ popupRef.current = new gl.Popup({
closeButton: false, closeButton: false,
closeOnClick: false, closeOnClick: false,
closeOnMove: false, closeOnMove: false,
@@ -260,7 +281,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
.setHTML(html) .setHTML(html)
.addTo(mapRef.current) .addTo(mapRef.current)
} }
}, []) }, [gl])
const hidePopup = useCallback(() => { const hidePopup = useCallback(() => {
if (popupRef.current) { if (popupRef.current) {
@@ -305,11 +326,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({ mapRef.current.flyTo({
center: marker.getLngLat(), center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14), zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: mapbox3d ? 45 : 0, pitch: enableMapbox3d ? 45 : 0,
duration: 600, duration: 600,
}) })
} catch { /* map not yet ready */ } } catch { /* map not yet ready */ }
}, [highlightMarker, mapbox3d]) }, [highlightMarker, enableMapbox3d])
const invalidateSize = useCallback(() => { const invalidateSize = useCallback(() => {
try { mapRef.current?.resize() } catch { /* map not yet ready */ } try { mapRef.current?.resize() } catch { /* map not yet ready */ }
@@ -320,39 +341,46 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// Build map once per style/token change. Markers and layers are rebuilt // 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. // inside the same effect so they stay in sync with the active style.
useEffect(() => { useEffect(() => {
if (!containerRef.current || !mapboxToken) return if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
mapboxgl.accessToken = mapboxToken if (!isMapLibre) mapboxgl.accessToken = mapboxToken
const items = buildItems(entries) const items = buildItems(entries)
itemsRef.current = items itemsRef.current = items
const bounds = new mapboxgl.LngLatBounds() const bounds = new gl.LngLatBounds()
items.forEach(i => bounds.extend([i.lng, i.lat])) items.forEach(i => bounds.extend([i.lng, i.lat]))
stableTrail.forEach(p => bounds.extend([p.lng, p.lat])) stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
const hasPoints = items.length > 0 || stableTrail.length > 0 const hasPoints = items.length > 0 || stableTrail.length > 0
const map = new mapboxgl.Map({ const mapOptions: Record<string, unknown> = {
container: containerRef.current, container: containerRef.current,
style: mapboxStyle, style: glStyle,
center: hasPoints ? bounds.getCenter() : [0, 30], center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1, zoom: hasPoints ? 2 : 1,
pitch: mapbox3d && fullScreen ? 45 : 0, pitch: enableMapbox3d && fullScreen ? 45 : 0,
attributionControl: true, attributionControl: true,
antialias: mapboxQuality, antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator', }
}) if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map mapRef.current = map
map.on('load', () => { map.on('load', () => {
if (mapbox3d) { if (enableMapbox3d) {
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map) if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current) if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
} }
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0) // Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
// stay pinned to their coordinates at every zoom and pitch. // 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 */ } try { map.setTerrain(null) } catch { /* noop */ }
} }
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (#1299).
if (!isMapLibre && isStandardFamily(glStyle)) {
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ }
}
// route trail — dashed line connecting entries in time order // route trail — dashed line connecting entries in time order
if (items.length > 1) { if (items.length > 1) {
@@ -383,7 +411,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// markers // markers
items.forEach((item) => { items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false) 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]) .setLngLat([item.lng, item.lat])
.addTo(map) .addTo(map)
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
@@ -400,7 +428,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
map.fitBounds(bounds, { map.fitBounds(bounds, {
padding: { top: 50, bottom: pb, left: 50, right: 50 }, padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16, maxZoom: 16,
pitch: mapbox3d && fullScreen ? 45 : 0, pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 0, duration: 0,
}) })
} catch { /* empty bounds */ } } catch { /* empty bounds */ }
@@ -418,7 +446,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
try { map.remove() } catch { /* noop */ } try { map.remove() } catch { /* noop */ }
mapRef.current = null mapRef.current = null
} }
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom]) }, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// external activeMarkerId → highlight + flyTo // external activeMarkerId → highlight + flyTo
useEffect(() => { useEffect(() => {
@@ -431,15 +459,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({ mapRef.current.flyTo({
center: marker.getLngLat(), center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12), zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: mapbox3d && fullScreen ? 45 : 0, pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 500, duration: 500,
}) })
} catch { /* map not ready */ } } catch { /* map not ready */ }
}, 50) }, 50)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen]) }, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen])
if (!mapboxToken) { if (!isMapLibre && !mapboxToken) {
return ( return (
<div <div
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }} 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 { useEffect, useState } from 'react'
import { Navigation } from 'lucide-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 * 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 * 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. * 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()) const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => { useEffect(() => {
+19 -4
View File
@@ -1,21 +1,36 @@
import { lazy, Suspense } from 'react'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView' 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 // Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly. // behind a toggle. Atlas is not affected — it imports Leaflet directly.
// //
// Offline maps: only the Leaflet renderer supports full pre-download (raster // 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 // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) { export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider) const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token) const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set, // 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. // 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} /> return <MapView {...props} />
} }
@@ -58,6 +58,35 @@ vi.mock('mapbox-gl', () => ({
})) }))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})) 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', () => ({ vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false), isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false), supportsCustom3d: vi.fn(() => false),
@@ -177,4 +206,25 @@ describe('MapViewGL', () => {
await act(async () => {}) await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first) 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()
})
}) })
+71 -39
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useMemo, useState, createElement } from 'react' import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' 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 { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox' import { ReservationMapboxOverlay } from './reservationsMapbox'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types' import type { Place, Reservation } from '../../types'
@@ -54,7 +57,9 @@ interface Props {
pois?: Poi[] pois?: Poi[]
onPoiClick?: (poi: Poi) => void onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => 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 { 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') const wrap = document.createElement('div')
// Do NOT set `position: relative` here — mapbox-gl ships // Do NOT set `position: relative` here — GL map libraries ship
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline // marker classes with `position: absolute` and rely on it. An inline
// `position: relative` here overrides the class, turns every marker into // `position: relative` here overrides the class, turns every marker into
// a static block element, and stacks them in document order inside the // a static block element, and stacks them in document order inside the
// canvas container. The result looks exactly like "markers drift as the // canvas container. The result looks exactly like "markers drift as the
@@ -169,29 +174,40 @@ export function MapViewGL({
pois = [], pois = [],
onPoiClick, onPoiClick,
onViewportChange, onViewportChange,
glProvider = 'mapbox-gl',
onMapReady, onMapReady,
}: Props) { }: 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 mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const mapLang = useSettingsStore(s => s.settings.language)
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 placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs) const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false) const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map()) 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 locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null) const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback / // Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change. // options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick) const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = 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 // Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip. // 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) const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange) const onViewportChangeRef = useRef(onViewportChange)
@@ -204,23 +220,25 @@ export function MapViewGL({
onClickRefs.current.map = onMapClick onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu onClickRefs.current.context = onMapContextMenu
// Build/rebuild the map on style/token/3d change // Build/rebuild the map on provider/style/token/3d change
useEffect(() => { useEffect(() => {
if (!containerRef.current || !mapboxToken) return if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
mapboxgl.accessToken = mapboxToken if (!isMapLibre) mapboxgl.accessToken = mapboxToken
const map = new mapboxgl.Map({ const mapOptions: Record<string, unknown> = {
container: containerRef.current, container: containerRef.current,
style: mapboxStyle, style: glStyle,
center: [center[1], center[0]], center: [center[1], center[0]],
zoom, zoom,
pitch: mapbox3d ? 45 : 0, pitch: enableMapbox3d ? 45 : 0,
attributionControl: true, attributionControl: true,
antialias: mapboxQuality, antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator', }
}) if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map mapRef.current = map
popupRef.current = new mapboxgl.Popup({ popupRef.current = new gl.Popup({
closeButton: false, closeButton: false,
closeOnClick: false, closeOnClick: false,
offset: 18, offset: 18,
@@ -234,12 +252,12 @@ export function MapViewGL({
;(window as any).__trek_map = map ;(window as any).__trek_map = map
map.on('load', () => { map.on('load', () => {
if (mapbox3d) { if (enableMapbox3d) {
// Terrain is only valuable on satellite styles — on clean vector // Terrain is only valuable on satellite styles — on clean vector
// styles it makes route lines drift off the HTML markers because // styles it makes route lines drift off the HTML markers because
// the lines snap to DEM height while markers stay at sea level. // the lines snap to DEM height while markers stay at sea level.
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map) if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) { if (supportsCustom3d(glStyle)) {
const dark = document.documentElement.classList.contains('dark') const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark) addCustom3dBuildings(map, dark)
} }
@@ -252,7 +270,7 @@ export function MapViewGL({
// non-satellite Standard style still looks great without terrain, // non-satellite Standard style still looks great without terrain,
// so flatten it out to keep markers pinned. (Satellite variants // so flatten it out to keep markers pinned. (Satellite variants
// are left alone — the DEM is what gives them their character.) // 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 */ } try { map.setTerrain(null) } catch { /* noop */ }
} }
// initial route source — kept around so updates can setData() cheaply // initial route source — kept around so updates can setData() cheaply
@@ -298,7 +316,7 @@ export function MapViewGL({
map.on('click', (e) => { map.on('click', (e) => {
const t = e.originalEvent.target as HTMLElement 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 } }) 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 // Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
@@ -309,7 +327,7 @@ export function MapViewGL({
} }
map.on('moveend', emitViewport) map.on('moveend', emitViewport)
map.once('idle', 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 // built-in rotate/pitch gesture, so we bind the "add place" action
// to the middle mouse button (button === 1) instead. // to the middle mouse button (button === 1) instead.
const canvas = map.getCanvasContainer() const canvas = map.getCanvasContainer()
@@ -356,7 +374,9 @@ export function MapViewGL({
const ll = marker.getLngLat() const ll = marker.getLngLat()
let alt = 0 let alt = 0
try { 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 if (typeof e === 'number' && Number.isFinite(e)) alt = e
} catch { /* terrain not ready */ } } catch { /* terrain not ready */ }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -368,7 +388,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 () => { return () => {
canvas.removeEventListener('mousedown', onAuxDown) canvas.removeEventListener('mousedown', onAuxDown)
@@ -389,7 +411,17 @@ export function MapViewGL({
mapRef.current = null mapRef.current = null
setMapReady(false) setMapReady(false)
} }
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only }, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (e.g. "India/भारत/India", #1299).
// Mapbox Standard exposes this via a basemap config property; classic and MapLibre styles
// are left as-is. Runs on load (mapReady) and whenever the UI language changes.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || isMapLibre || !isStandardFamily(glStyle)) return
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support the basemap language property */ }
}, [mapLang, mapReady, isMapLibre, glStyle])
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch // Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
// simultaneous thumb arrivals into one re-render. // simultaneous thumb arrivals into one re-render.
@@ -489,12 +521,12 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain, // pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets // but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift. // 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]) .setLngLat([place.lng, place.lat])
.addTo(map) .addTo(map)
markersRef.current.set(place.id, m) 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 // Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them). // planned-place markers so they don't cluster or get confused with them).
@@ -511,10 +543,10 @@ export function MapViewGL({
}) })
el.addEventListener('mouseleave', () => { popupRef.current?.remove() }) el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) }) 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) poiMarkersRef.current.push(m)
} }
}, [pois, mapReady]) }, [pois, mapReady, glProvider])
// Update route geojson // Update route geojson
useEffect(() => { useEffect(() => {
@@ -578,7 +610,7 @@ export function MapViewGL({
showStats: showReservationStats, showStats: showReservationStats,
showEndpointLabels, showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id), onEndpointClick: (id) => onReservationClickRef.current?.(id),
}) }, gl.Marker as any)
} }
reservationOverlayRef.current.update(visibleReservations, { reservationOverlayRef.current.update(visibleReservations, {
showConnections: true, showConnections: true,
@@ -586,7 +618,7 @@ export function MapViewGL({
showEndpointLabels, showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id), onEndpointClick: (id) => onReservationClickRef.current?.(id),
}) })
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady]) }, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
// Fit bounds on fitKey change — matches the Leaflet BoundsController // Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => { const paddingOpts = useMemo(() => {
@@ -606,14 +638,14 @@ export function MapViewGL({
const target = dayPlaces.length > 0 ? dayPlaces : places const target = dayPlaces.length > 0 ? dayPlaces : places
const valid = target.filter(p => p.lat && p.lng) const valid = target.filter(p => p.lat && p.lng)
if (valid.length === 0) return if (valid.length === 0) return
const bounds = new mapboxgl.LngLatBounds() const bounds = new gl.LngLatBounds()
valid.forEach(p => bounds.extend([p.lng, p.lat])) valid.forEach(p => bounds.extend([p.lng, p.lat]))
const run = () => { const run = () => {
try { try {
map.fitBounds(bounds, { map.fitBounds(bounds, {
padding: paddingOpts, padding: paddingOpts,
maxZoom: 15, maxZoom: 15,
pitch: mapbox3d ? 45 : 0, pitch: enableMapbox3d ? 45 : 0,
duration: 400, duration: 400,
}) })
} catch { /* noop */ } } catch { /* noop */ }
@@ -632,7 +664,7 @@ export function MapViewGL({
map.flyTo({ map.flyTo({
center: [target.lng, target.lat], center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14), zoom: Math.max(map.getZoom(), 14),
pitch: mapbox3d ? 45 : 0, pitch: enableMapbox3d ? 45 : 0,
duration: 400, duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel // 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 // so the selected pin lands in the centre of the *visible* map area rather
@@ -640,7 +672,7 @@ export function MapViewGL({
padding: paddingOpts, padding: paddingOpts,
}) })
} catch { /* noop */ } } 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 // External center/zoom prop changes — jump without animation
useEffect(() => { useEffect(() => {
@@ -663,7 +695,7 @@ export function MapViewGL({
} }
if (!userPosition) return if (!userPosition) return
const apply = () => { const apply = () => {
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map) if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any)
locationMarkerRef.current.update(userPosition) locationMarkerRef.current.update(userPosition)
if (trackingMode === 'follow') { if (trackingMode === 'follow') {
// easeTo is gentler than flyTo for continuous updates // easeTo is gentler than flyTo for continuous updates
@@ -679,9 +711,9 @@ export function MapViewGL({
} }
if (map.loaded()) apply() if (map.loaded()) apply()
else map.once('load', apply) else map.once('load', apply)
}, [userPosition, trackingMode]) }, [userPosition, trackingMode, glProvider])
if (!mapboxToken) { if (!isMapLibre && !mapboxToken) {
return ( 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="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"> <div className="text-sm text-zinc-500">
+17 -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' const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -60,7 +62,7 @@ export async function calculateRoute(
coordinates, coordinates,
distance, distance,
duration, duration,
distanceText: formatDistance(distance), distanceText: formatRouteDistance(distance),
durationText: formatDuration(duration), durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration), drivingText: formatDuration(drivingDuration),
@@ -218,7 +220,7 @@ export async function calculateSegments(
duration: leg.duration, duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance), distanceText: formatRouteDistance(leg.distance),
} }
}) })
} }
@@ -238,7 +240,9 @@ export async function calculateRouteWithLegs(
} }
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') 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) const cached = routeCache.get(cacheKey)
if (cached) return cached if (cached) return cached
@@ -265,7 +269,7 @@ export async function calculateRouteWithLegs(
duration: leg.duration, duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance), distanceText: formatRouteDistance(leg.distance),
durationText: formatDuration(leg.duration), durationText: formatDuration(leg.duration),
} }
} }
@@ -280,11 +284,16 @@ export async function calculateRouteWithLegs(
return result return result
} }
function formatDistance(meters: number): string { function getDistanceUnit(): DistanceUnit {
if (meters < 1000) { 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 `${Math.round(meters)} m`
} }
return `${(meters / 1000).toFixed(1)} km` return formatDistance(meters / 1000, unit)
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
MAPBOX_DEFAULT_STYLE,
OPENFREEMAP_DEFAULT_STYLE,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleForActiveProvider,
basemapLanguage,
} 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)
})
it('basemapLanguage maps TREK UI codes to basemap label codes (#1299)', () => {
// Pass-through for plain ISO 639-1 codes.
expect(basemapLanguage('en')).toBe('en')
expect(basemapLanguage('de')).toBe('de')
expect(basemapLanguage('fr')).toBe('fr')
// TREK-specific overrides.
expect(basemapLanguage('br')).toBe('pt')
expect(basemapLanguage('gr')).toBe('el')
expect(basemapLanguage('zh')).toBe('zh-Hans')
expect(basemapLanguage('zhTw')).toBe('zh-Hant')
expect(basemapLanguage('zh-TW')).toBe('zh-Hant')
// Falls back to English when unset.
expect(basemapLanguage(undefined)).toBe('en')
expect(basemapLanguage('')).toBe('en')
})
})
+87
View File
@@ -0,0 +1,87 @@
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)
}
// A few TREK UI language codes differ from what the GL basemap expects for its labels.
const BASEMAP_LANG_OVERRIDES: Record<string, string> = {
br: 'pt', // TREK 'br' = Brazilian Portuguese
gr: 'el', // TREK 'gr' = Greek
zh: 'zh-Hans',
zhTw: 'zh-Hant',
'zh-TW': 'zh-Hant',
}
/**
* Maps a TREK UI language code to the label language the GL basemap expects. Used to pin
* Mapbox Standard's basemap labels to the user's language so they don't fall back to the
* browser/OS locale and stack multiple scripts per place (#1299).
*/
export function basemapLanguage(uiLang: string | undefined): string {
const code = (uiLang || 'en').trim()
return BASEMAP_LANG_OVERRIDES[code] ?? code
}
@@ -1,6 +1,13 @@
import mapboxgl from 'mapbox-gl' import type mapboxgl from 'mapbox-gl'
import type { GeoPosition } from '../../hooks/useGeolocation' 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 // 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 // heading cone via a CSS rotation so the DOM stays stable across updates
// and mapbox doesn't get confused about which element to position. // 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 // 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 // and clean up. Keeps its own DOM element and GeoJSON source so it can
// coexist with the regular trip markers. // coexist with the regular trip markers.
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle { export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle {
ensurePulseStyle() ensurePulseStyle()
const { root, cone } = buildLocationEl() const { root, cone } = buildLocationEl()
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' }) const marker = new MarkerCtor({ element: root, anchor: 'center' })
const ensureAccuracyLayer = () => { const ensureAccuracyLayer = () => {
if (map.getSource('trek-location-accuracy')) return if (map.getSource('trek-location-accuracy')) return
@@ -8,7 +8,7 @@
import { createElement } from 'react' import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' 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 { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared' import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types' import type { Reservation, ReservationEndpoint } from '../../types'
@@ -220,18 +220,29 @@ export interface ReservationOverlayOptions {
onEndpointClick?: (reservationId: number) => void 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 { export class ReservationMapboxOverlay {
private map: mapboxgl.Map private map: mapboxgl.Map
private items: TransportItem[] = [] private items: TransportItem[] = []
private opts: ReservationOverlayOptions private opts: ReservationOverlayOptions
private endpointMarkers: mapboxgl.Marker[] = [] private MarkerCtor: MarkerConstructor
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = [] private endpointMarkers: GlMarker[] = []
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private rerender: () => void private rerender: () => void
private destroyed = false private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) { constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) {
this.map = map this.map = map
this.opts = opts this.opts = opts
this.MarkerCtor = MarkerCtor
this.rerender = () => { if (!this.destroyed) this.render() } this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer() this.setupLayer()
map.on('zoomend', this.rerender) map.on('zoomend', this.rerender)
@@ -350,7 +361,7 @@ export class ReservationMapboxOverlay {
this.opts.onEndpointClick?.(item.res.id) 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]) .setLngLat([ep.lng, ep.lat])
.addTo(map) .addTo(map)
this.endpointMarkers.push(marker) this.endpointMarkers.push(marker)
@@ -168,6 +168,34 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText('D2')).toBeInTheDocument() expect(screen.getByText('D2')).toBeInTheDocument()
}) })
// ── #1330: route tools for a single optimizable place ───────────────────────
it('FE-PLANNER-DAYPLAN-005b: route tools show for one located place with a bookend hotel (#1330)', () => {
const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.85, place_lng: 2.35 }]
render(<DayPlanSidebar {...makeDefaultProps({
days: [day, day2], places: [place], assignments: { '10': [assignment] },
accommodations: accommodations as any, selectedDayId: 10,
})} />)
// With accommodation optimization on, one located place is routable (hotel → place → hotel),
// so the route tools (here the Google Maps export button) must be visible.
expect(screen.getByRole('button', { name: 'Open in Google Maps' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-005c: route tools stay hidden for one place with no bookend hotel (#1330 guard)', () => {
const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] },
accommodations: [], selectedDayId: 10,
})} />)
// No accommodation to bookend the lone place, so nothing routable — tools stay hidden.
expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument()
})
// ── Day expansion/collapse ────────────────────────────────────────────── // ── Day expansion/collapse ──────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => { it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => {
@@ -35,6 +35,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
interface DayPlanSidebarProps { interface DayPlanSidebarProps {
tripId: number tripId: number
@@ -154,6 +155,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({}) const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({}) const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) 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 legsAbortRef = useRef<AbortController | null>(null)
const [draggingId, setDraggingId] = useState(null) const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set()) 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 // 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. // the "optimize from accommodation" setting is on and the day has a hotel.
const day = days.find(d => d.id === selectedDayId) const day = days.find(d => d.id === selectedDayId)
const { morning: startHotel, evening: endHotel } = const bookends = day && optimizeFromAccommodation !== false
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {} ? 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 || '' 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 // 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. // legs connect even when the day starts or ends with a booking rather than a place. Track
const wayPts: { lat: number; lng: number }[] = [] // 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) { for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { 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') { } else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId) const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
if (from) wayPts.push({ lat: from.lat, lng: from.lng }) if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false })
if (to) wayPts.push({ lat: to.lat, lng: to.lng }) if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false })
} }
} }
const firstWay = wayPts[0] const firstWay = wayPts[0]
const lastWay = wayPts[wayPts.length - 1] const lastWay = wayPts[wayPts.length - 1]
const wantTop = !!(startHotel && firstWay) const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere))
const wantBottom = !!(endHotel && lastWay) const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight))
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return } 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) } 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) => { const openAddNote = (dayId, e) => {
e?.stopPropagation() e?.stopPropagation()
@@ -1046,6 +1055,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) { const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props) const S = useDayPlanSidebar(props)
// Needed by the route-tools visibility gate in the render below (#1330); the hook
// keeps its own copy, so read it reactively here in the component scope too.
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const { const {
tripId, tripId,
trip, trip,
@@ -1231,6 +1243,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const cost = dayTotalCost(day.id, assignments, currency) const cost = dayTotalCost(day.id, assignments, currency)
const formattedDate = formatDate(day.date, locale) const formattedDate = formatDate(day.date, locale)
const loc = da.find(a => a.place?.lat && a.place?.lng) const loc = da.find(a => a.place?.lat && a.place?.lng)
// Route tools normally need 2+ stops, but a single located place is still
// routable when accommodation optimization can bookend it with a hotel
// (hotel → place → hotel, the same line the map draws) — otherwise the tools
// vanish on such a day (#1330). Purely additive to the 2+ case.
const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null
const hasRouteBookend = !!(
(routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) ||
(routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null)
)
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend)
const isDragTarget = dragOverDayId === day.id const isDragTarget = dragOverDayId === day.id
const merged = mergedItemsMap[day.id] || [] const merged = mergedItemsMap[day.id] || []
const dayNoteUi = noteUi[day.id] const dayNoteUi = noteUi[day.id]
@@ -1595,14 +1617,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}} }}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} 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) }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [ onContextMenu={e => {
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, const googleMapsUrl = getGoogleMapsUrlForPlace(place)
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, ctxMenu.open(e, [
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
(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') }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
{ divider: true }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, 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 => { onMouseEnter={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id)) if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'var(--bg-hover)' e.currentTarget.style.background = 'var(--bg-hover)'
@@ -2151,8 +2176,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)} )}
</div> </div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend, #1330) */}
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && ( {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button <button
@@ -2288,4 +2313,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). // Populated from a maps-search pick (not part of the initial blank form).
phone?: string phone?: string
google_place_id?: string google_place_id?: string
google_ftid?: string
osm_id?: string osm_id?: string
} }
@@ -217,6 +217,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
address: resolved.address || prev.address, address: resolved.address || prev.address,
lat: String(resolved.lat), lat: String(resolved.lat),
lng: String(resolved.lng), lng: String(resolved.lng),
google_ftid: resolved.google_ftid || prev.google_ftid,
})) }))
setMapsResults([]) setMapsResults([])
setMapsSearch('') setMapsSearch('')
@@ -241,6 +242,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: result.lat || prev.lat, lat: result.lat || prev.lat,
lng: result.lng || prev.lng, lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id, 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, osm_id: result.osm_id || prev.osm_id,
website: result.website || prev.website, website: result.website || prev.website,
phone: result.phone || prev.phone, phone: result.phone || prev.phone,
@@ -618,6 +618,22 @@ describe('PlaceInspector', () => {
expect(mapsBtn).toBeTruthy(); 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 ────────────────── // ── No files section when no upload handler and no files ──────────────────
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => { 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 { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime, formatTime } from '../../utils/formatters' import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { formatDistance, formatElevation } from '../../utils/units'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
const detailsCache = new Map() const detailsCache = new Map()
@@ -122,6 +124,7 @@ export default function PlaceInspector({
const { t, locale, language } = useTranslation() const { t, locale, language } = useTranslation()
const toast = useToast() const toast = useToast()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const [hoursExpanded, setHoursExpanded] = useState(false) const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false) const [filesExpanded, setFilesExpanded] = useState(false)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
@@ -162,6 +165,11 @@ export default function PlaceInspector({
const openingHours = googleDetails?.opening_hours || null const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? 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 selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date) const weekdayIndex = getWeekdayIndex(selectedDay?.date)
@@ -274,7 +282,8 @@ export default function PlaceInspector({
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded} <PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles} setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded} onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} /> fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
distanceUnit={distanceUnit} />
</div> </div>
@@ -288,14 +297,10 @@ export default function PlaceInspector({
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} /> <ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
) )
)} )}
{googleDetails?.google_maps_url && ( {googleMapsUrl && (
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />} <ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} /> 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) && ( {(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />} <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>} /> 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, 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 ( return (
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}> <div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && ( {openingHours && openingHours.length > 0 && (
@@ -775,20 +780,20 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}> <div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" /> <MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`} {formatDistance(distKm, distanceUnit)}
</div> </div>
{hasEle && ( {hasEle && (
<> <>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}> <div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" /> <Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m {formatElevation(maxEle, distanceUnit)}
</div> </div>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}> <div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" /> <Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m {formatElevation(minEle, distanceUnit)}
</div> </div>
<div className="text-content-muted" style={{ fontSize: 12 }}> <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> </div>
</> </>
)} )}
@@ -124,6 +124,40 @@ describe('PlacesSidebar', () => {
expect(screen.getByText('Central Park')).toBeInTheDocument(); 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', () => { it('FE-COMP-PLACES-010: shows place count', () => {
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })]; const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
render(<PlacesSidebar {...defaultProps} places={places} />); render(<PlacesSidebar {...defaultProps} places={places} />);
@@ -5,7 +5,7 @@ export function PlacesList(S: SidebarState) {
const { const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace, filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId, categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
} = S } = S
return ( return (
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <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} onAssignToDay={onAssignToDay}
toggleSelected={toggleSelected} toggleSelected={toggleSelected}
setDayPickerPlace={setDayPickerPlace} setDayPickerPlace={setDayPickerPlace}
registerPlaceRow={registerPlaceRow}
/> />
) )
}) })
@@ -21,17 +21,21 @@ interface MemoPlaceRowProps {
onAssignToDay: (placeId: number, dayId?: number) => void onAssignToDay: (placeId: number, dayId?: number) => void
toggleSelected: (id: number) => void toggleSelected: (id: number) => void
setDayPickerPlace: (place: any) => void setDayPickerPlace: (place: any) => void
registerPlaceRow: (placeId: number, element: HTMLDivElement | null) => void
} }
export const MemoPlaceRow = React.memo(function MemoPlaceRow({ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked, place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, t, selectMode, selectedDayId, canEditPlaces, isMobile, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
}: MemoPlaceRowProps) { }: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry) const hasGeometry = Boolean(place.route_geometry)
return ( return (
<div <div
key={place.id} key={place.id}
ref={element => registerPlaceRow(place.id, element)}
aria-selected={isSelected}
data-place-id={place.id}
draggable={!selectMode} draggable={!selectMode}
onDragStart={e => { onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id)) 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 { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types' import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
export interface PlacesSidebarProps { export interface PlacesSidebarProps {
tripId: number tripId: number
@@ -59,6 +60,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [sidebarDragOver, setSidebarDragOver] = useState(false) const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0) const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null) const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const placeRowRefs = useRef(new Map<number, HTMLDivElement>())
const lastAutoScrolledPlaceIdRef = useRef<number | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) { if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop scrollContainerRef.current.scrollTop = initialScrollTop
@@ -197,6 +200,28 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
return true return true
}), [places, filter, categoryFilters, search, plannedIds]) }), [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) => const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === 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 openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
const selDayId = selectedDayIdRef.current const selDayId = selectedDayIdRef.current
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
ctxMenu.open(e, [ ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) }, canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) }, 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.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 }, { divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) }, 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, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace, exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays, 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'); 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 () => { it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined); const updateSetting = vi.fn().mockResolvedValue(undefined);
@@ -6,12 +6,14 @@ import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
import Section from './Section' import Section from './Section'
import type { DistanceUnit } from '../../types'
export default function DisplaySettingsTab(): React.ReactElement { export default function DisplaySettingsTab(): React.ReactElement {
const { settings, updateSetting } = useSettingsStore() const { settings, updateSetting } = useSettingsStore()
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius') const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || 'metric')
const [langOpen, setLangOpen] = useState(false) const [langOpen, setLangOpen] = useState(false)
const langDropdownRef = useRef<HTMLDivElement | null>(null) const langDropdownRef = useRef<HTMLDivElement | null>(null)
@@ -28,6 +30,10 @@ export default function DisplaySettingsTab(): React.ReactElement {
setTempUnit(settings.temperature_unit || 'celsius') setTempUnit(settings.temperature_unit || 'celsius')
}, [settings.temperature_unit]) }, [settings.temperature_unit])
useEffect(() => {
setDistanceUnit(settings.distance_unit || 'metric')
}, [settings.distance_unit])
return ( return (
<Section title={t('settings.display')} icon={Palette}> <Section title={t('settings.display')} icon={Palette}>
{/* Display currency */} {/* Display currency */}
@@ -200,6 +206,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div> </div>
</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 */} {/* Time Format */}
<div> <div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label> <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 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 { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView' import { MapView } from '../Map/MapView'
import MapboxPreview from './MapboxPreview' import GlMapPreview from './MapboxPreview'
import Section from './Section' import Section from './Section'
import ToggleSwitch from './ToggleSwitch' import ToggleSwitch from './ToggleSwitch'
import type { Place } from '../../types' import type { Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
type GlMapProvider,
} from '../Map/glProviders'
interface MapPreset { interface MapPreset {
name: string 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' }, { 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 // Tag → chip color mapping. Keeps the dropdown readable at a glance so a
// user scanning the list can spot 3D / Satellite / Apple-like styles. // user scanning the list can spot 3D / Satellite / Apple-like styles.
const TAG_STYLES: Record<string, string> = { 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', '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', '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', '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 }) { 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 { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const presets = getStylePresets(provider)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -84,7 +75,10 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
return () => document.removeEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc)
}, [open]) }, [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 ( return (
<div ref={ref} className="relative"> <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="flex items-center gap-2 min-w-0">
<span className="text-slate-900 dark:text-white truncate"> <span className="text-slate-900 dark:text-white truncate">
{selected ? selected.name : t('settings.mapStylePlaceholder')} {selected ? selected.name : placeholder}
</span> </span>
{selected && ( {selected && (
<span className="flex items-center gap-1 flex-shrink-0"> <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>
)} )}
</span> </span>
@@ -107,7 +101,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
</button> </button>
{open && ( {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"> <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 const isActive = preset.url === value
return ( return (
<button <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="flex items-center gap-2 flex-wrap">
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span> <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> </span>
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />} {isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
</button> </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 { export default function MapSettingsTab(): React.ReactElement {
const { settings, updateSettings } = useSettingsStore() const { settings, updateSettings } = useSettingsStore()
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
const initialProvider = normalizeProvider(settings.map_provider)
const [saving, setSaving] = useState(false) 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 [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '') 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 [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true) const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566) 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) const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
useEffect(() => { useEffect(() => {
setProvider((settings.map_provider as Provider) || 'leaflet') const nextProvider = normalizeProvider(settings.map_provider)
setProvider(nextProvider)
setMapTileUrl(settings.map_tile_url || '') setMapTileUrl(settings.map_tile_url || '')
setMapboxToken(settings.mapbox_access_token || '') 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) setMapbox3d(settings.mapbox_3d_enabled !== false)
setMapboxQuality(settings.mapbox_quality_mode === true) setMapboxQuality(settings.mapbox_quality_mode === true)
setDefaultLat(settings.default_lat || 48.8566) setDefaultLat(settings.default_lat || 48.8566)
@@ -186,11 +198,15 @@ export default function MapSettingsTab(): React.ReactElement {
const saveMapSettings = async (): Promise<void> => { const saveMapSettings = async (): Promise<void> => {
setSaving(true) setSaving(true)
try { 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({ await updateSettings({
map_provider: provider, map_provider: provider,
map_tile_url: mapTileUrl, map_tile_url: mapTileUrl,
mapbox_access_token: mapboxToken, mapbox_access_token: mapboxToken,
mapbox_style: mapboxStyle, ...stylePatch,
mapbox_3d_enabled: mapbox3d, mapbox_3d_enabled: mapbox3d,
mapbox_quality_mode: mapboxQuality, mapbox_quality_mode: mapboxQuality,
default_lat: parseFloat(String(defaultLat)), 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 // 3D is available on every style now — pure satellite uses the
// mapbox-streets-v8 tileset as a fallback building source. // mapbox-streets-v8 tileset as a fallback building source.
const supports3d = true const supports3d = true
const changeProvider = (nextProvider: Provider) => {
setProvider(nextProvider)
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
}
return ( return (
<Section title={t('settings.map')} icon={Map}> <Section title={t('settings.map')} icon={Map}>
{/* Provider picker — big cards so the choice is obvious */} {/* Provider picker — big cards so the choice is obvious */}
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label> <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 <button
type="button" type="button"
onClick={() => setProvider('leaflet')} onClick={() => changeProvider('leaflet')}
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${ className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'leaflet' provider === 'leaflet'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200' ? '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>
<button <button
type="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 ${ className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'mapbox-gl' provider === 'mapbox-gl'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200' ? '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')} {t('settings.mapExperimental')}
</span> </span>
</button> </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> </div>
<p className="text-xs text-slate-400 mt-2"> <p className="text-xs text-slate-400 mt-2">
{t('settings.mapProviderHint')} {t('settings.mapProviderHint')}
@@ -281,9 +319,10 @@ export default function MapSettingsTab(): React.ReactElement {
</div> </div>
)} )}
{/* Mapbox GL settings */} {/* GL settings */}
{provider === 'mapbox-gl' && ( {provider !== 'leaflet' && (
<div className="space-y-3"> <div className="space-y-3">
{provider === 'mapbox-gl' && (
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
<input <input
@@ -300,24 +339,27 @@ export default function MapSettingsTab(): React.ReactElement {
</a> </a>
</p> </p>
</div> </div>
)}
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
<div className="mb-2"> <div className="mb-2">
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} /> <StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
</div> </div>
<input <input
type="text" type="text"
value={mapboxStyle} value={mapboxStyle}
onChange={(e) => setMapboxStyle(e.target.value)} 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" 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"> <p className="text-xs text-slate-400 mt-1">
{t('settings.mapStyleHint')} {provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
</p> </p>
</div> </div>
{provider === 'mapbox-gl' && (
<>
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${ <div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
supports3d supports3d
? 'border-slate-200 dark:border-slate-700' ? '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"> <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')} <strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
</div> </div>
</>
)}
</div> </div>
)} )}
@@ -383,8 +427,9 @@ export default function MapSettingsTab(): React.ReactElement {
<div> <div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}> <div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{provider === 'mapbox-gl' ? ( {provider !== 'leaflet' ? (
<MapboxPreview <GlMapPreview
provider={provider}
token={mapboxToken} token={mapboxToken}
style={mapboxStyle} style={mapboxStyle}
lat={parseFloat(String(defaultLat)) || 48.8566} 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, // Zoom in close so the style's character (3D buildings,
// satellite texture, label density) is immediately visible. // satellite texture, label density) is immediately visible.
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)} zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
enable3d={mapbox3d && supports3d} enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
quality={mapboxQuality} quality={provider === 'mapbox-gl' && mapboxQuality}
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }} onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
/> />
) : ( ) : (
@@ -1,10 +1,14 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
interface Props { interface Props {
token: string provider?: GlMapProvider
token?: string
style: string style: string
lat: number lat: number
lng: number lng: number
@@ -14,37 +18,44 @@ interface Props {
onClick?: (latlng: { lat: number; lng: number }) => void 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 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) const onClickRef = useRef(onClick)
onClickRef.current = 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(() => { useEffect(() => {
if (!containerRef.current || !token) return if (!containerRef.current || (!isMapLibre && !token)) return
mapboxgl.accessToken = token if (!isMapLibre) mapboxgl.accessToken = token
const map = new mapboxgl.Map({ const mapOptions: Record<string, unknown> = {
container: containerRef.current, container: containerRef.current,
style, style: glStyle,
center: [lng, lat], center: [lng, lat],
zoom, zoom,
pitch: enable3d ? 45 : 0, pitch: enableMapbox3d ? 45 : 0,
attributionControl: true, attributionControl: true,
antialias: quality, antialias: quality,
projection: quality ? 'globe' : 'mercator', }
}) if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map mapRef.current = map
map.on('load', () => { map.on('load', () => {
if (enable3d) { if (enableMapbox3d) {
if (!isStandardFamily(style)) addTerrainAndSky(map) if (!isStandardFamily(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(style)) { if (supportsCustom3d(glStyle)) {
const dark = document.documentElement.classList.contains('dark') const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark) addCustom3dBuildings(map, dark)
} }
} }
if (style === 'mapbox://styles/mapbox/standard') { if (glStyle === MAPBOX_DEFAULT_STYLE) {
try { map.setTerrain(null) } catch { /* noop */ } 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 */ } try { map.remove() } catch { /* noop */ }
mapRef.current = null mapRef.current = null
} }
}, [token, style, enable3d, quality]) }, [provider, token, glStyle, enableMapbox3d, quality])
// Recenter without rebuilding the map when lat/lng/zoom change externally // Recenter without rebuilding the map when lat/lng/zoom change externally
useEffect(() => { 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 */ } try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
}, [lat, lng, zoom]) }, [lat, lng, zoom])
if (!token) { if (!isMapLibre && !token) {
return ( 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"> <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 Enter a Mapbox access token to preview
@@ -62,16 +62,17 @@ function CTALink({
if (notice.cta.kind === 'nav') { if (notice.cta.kind === 'nav') {
navigate(notice.cta.href); navigate(notice.cta.href);
if (notice.dismissible) onDismiss(); if (notice.dismissible) onDismiss();
} else if (notice.cta.kind === 'link') {
window.open(notice.cta.href, '_blank', 'noopener,noreferrer');
} else { } else {
runNoticeAction(notice.cta.actionId, { navigate }); runNoticeAction(notice.cta.actionId, { navigate });
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; if (notice.cta.dismissOnAction !== false) onDismiss();
if (actionCta.dismissOnAction !== false) onDismiss();
} }
} }
if (!notice.cta) return null; if (!notice.cta) return null;
if (notice.cta.kind === 'nav') { if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
return ( return (
<a <a
href={notice.cta.href} href={notice.cta.href}
@@ -1,10 +1,26 @@
import React, { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js'; import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
import { ModalRenderer } from './SystemNoticeModal.js'; import { ModalRenderer } from './SystemNoticeModal.js';
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js'; import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
// Mobile breakpoint matches the modal sheet's (max-width: 639px).
function useIsMobile() {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
);
useEffect(() => {
const mq = window.matchMedia?.('(max-width: 639px)');
if (!mq) return;
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return isMobile;
}
export function SystemNoticeHost() { export function SystemNoticeHost() {
const { notices, loaded } = useSystemNoticeStore(); const { notices, loaded } = useSystemNoticeStore();
const isMobile = useIsMobile();
// Notices are fetched by authStore after login (see App.tsx / authStore modification). // Notices are fetched by authStore after login (see App.tsx / authStore modification).
// Cold-session fetch (page reload with valid session) is triggered here: // Cold-session fetch (page reload with valid session) is triggered here:
@@ -17,9 +33,12 @@ export function SystemNoticeHost() {
if (!loaded) return null; if (!loaded) return null;
const modals = notices.filter(n => n.display === 'modal'); // desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile.
const banners = notices.filter(n => n.display === 'banner'); const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices;
const toasts = notices.filter(n => n.display === 'toast');
const modals = visible.filter(n => n.display === 'modal');
const banners = visible.filter(n => n.display === 'banner');
const toasts = visible.filter(n => n.display === 'toast');
return ( return (
<> <>
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react'; import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight, Coffee } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize'; import rehypeSanitize from 'rehype-sanitize';
@@ -36,6 +36,33 @@ const SEVERITY_ACCENT: Record<string, string> = {
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950', critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
}; };
// Real brand marks (simple-icons single-path logos) for the support buttons, so the
// Buy Me a Coffee / Ko-fi buttons carry their actual logo instead of a generic
// lucide glyph. Tinted via currentColor.
const BRAND_ICON_PATHS: Record<string, string> = {
buymeacoffee:
'M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z',
kofi:
'M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298',
};
function brandForHref(href?: string): string | null {
if (!href) return null;
if (href.includes('buymeacoffee')) return 'buymeacoffee';
if (href.includes('ko-fi.com') || href.includes('kofi')) return 'kofi';
return null;
}
function BrandIcon({ brand, size = 18, className }: { brand: string; size?: number; className?: string }) {
const d = BRAND_ICON_PATHS[brand];
if (!d) return null;
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" className={className} aria-hidden="true">
<path d={d} />
</svg>
);
}
interface Props { interface Props {
notices: SystemNoticeDTO[]; notices: SystemNoticeDTO[];
} }
@@ -46,12 +73,14 @@ interface ContentProps {
title: string; title: string;
body: string; body: string;
ctaLabel: string | null; ctaLabel: string | null;
secondaryCtaLabel: string | null;
titleId: string; titleId: string;
bodyId: string; bodyId: string;
isDark: boolean; isDark: boolean;
onDismiss: () => void; onDismiss: () => void;
onDismissAll: () => void; onDismissAll: () => void;
onCTA: () => void; onCTA: () => void;
onSecondaryCTA: () => void;
// Pager // Pager
total: number; total: number;
currentPage: number; currentPage: number;
@@ -61,7 +90,7 @@ interface ContentProps {
onGoto: (i: number) => void; onGoto: (i: number) => void;
} }
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) { function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isLastPage = total <= 1 || currentPage === total - 1; const isLastPage = total <= 1 || currentPage === total - 1;
@@ -70,6 +99,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon ? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
: DefaultIcon; : DefaultIcon;
// Real brand logo for each support button, detected from the link target.
const primaryBrand = notice.cta?.kind === 'link' ? brandForHref(notice.cta.href) : null;
const secondaryBrand = notice.secondaryCta?.kind === 'link' ? brandForHref(notice.secondaryCta.href) : null;
return ( return (
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}> <div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
{/* Dismiss X button — only on last page so users read all notices */} {/* Dismiss X button — only on last page so users read all notices */}
@@ -104,17 +137,9 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
{/* Special warm header for Heart icon (thank-you notice) */} {/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && ( {notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center"> <div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-6 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} /> <div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<div className="relative flex items-center justify-center gap-3"> <h2 id={titleId} className="relative text-xl font-bold text-white leading-tight">{title}</h2>
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div> </div>
)} )}
@@ -197,24 +222,27 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</div> </div>
)} )}
{/* Highlights */} {/* Highlights — compact pills */}
{notice.highlights && notice.highlights.length > 0 && ( {notice.highlights && notice.highlights.length > 0 && (
<ul className="mx-auto mb-4 space-y-2"> <div className="flex flex-wrap justify-center gap-2 mb-4">
{notice.highlights.map((h, i) => { {notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null ? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
: null; : null;
return ( return (
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300"> <span
key={i}
className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 dark:bg-slate-800 px-3 py-1 text-xs font-medium text-slate-700 dark:text-slate-300"
>
{HIcon {HIcon
? <HIcon size={16} className="text-blue-500 shrink-0" /> ? <HIcon size={13} className="text-indigo-500 dark:text-indigo-400 shrink-0" />
: <span className="text-blue-500 shrink-0"></span> : <span className="text-indigo-500 shrink-0"></span>
} }
{t(h.labelKey)} {t(h.labelKey)}
</li> </span>
); );
})} })}
</ul> </div>
)} )}
</div> </div>
</div> </div>
@@ -270,16 +298,37 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</div> </div>
)} )}
{/* CTA + dismiss link */} {/* CTA(s) + dismiss link */}
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{ctaLabel && isLastPage ? ( {ctaLabel && isLastPage ? (
<button <div className="flex w-full flex-col sm:flex-row gap-2.5">
id={`notice-cta-${notice.id}`} <button
onClick={onCTA} id={`notice-cta-${notice.id}`}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors" onClick={onCTA}
> className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
{ctaLabel} notice.cta?.kind === 'link'
</button> ? 'bg-[#FFDD00] text-[#0D0C22] hover:brightness-95'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{primaryBrand ? <BrandIcon brand={primaryBrand} size={18} /> : (notice.cta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
{ctaLabel}
</button>
{secondaryCtaLabel && (
<button
id={`notice-cta2-${notice.id}`}
onClick={onSecondaryCTA}
className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
notice.secondaryCta?.kind === 'link'
? 'bg-[#FF5E5B] text-white hover:brightness-95'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{secondaryBrand ? <BrandIcon brand={secondaryBrand} size={18} /> : (notice.secondaryCta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
{secondaryCtaLabel}
</button>
)}
</div>
) : (notice.dismissible || isLastPage) && ( ) : (notice.dismissible || isLastPage) && (
<button <button
id={`notice-cta-${notice.id}`} id={`notice-cta-${notice.id}`}
@@ -289,14 +338,6 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
{t('common.ok')} {t('common.ok')}
</button> </button>
)} )}
{notice.dismissible && isLastPage && ctaLabel && (
<button
onClick={onDismiss}
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
Not now
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -510,21 +551,22 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
notices.forEach(n => dismiss(n.id)); notices.forEach(n => dismiss(n.id));
} }
function handleCTA() { function runCta(cta: SystemNoticeDTO['cta']) {
if (!notice) return; if (!cta) { handleDismissAll(); return; }
if (!notice.cta) { if (cta.kind === 'nav') {
handleDismissAll(); navigate(cta.href);
return; if (notice?.dismissible !== false) handleDismissAll();
} } else if (cta.kind === 'link') {
if (notice.cta.kind === 'nav') { // External link (e.g. Buy Me a Coffee / Ko-fi): open in a new tab and leave the
navigate(notice.cta.href); // notice open so the user can use the other button too.
if (notice.dismissible !== false) handleDismissAll(); window.open(cta.href, '_blank', 'noopener,noreferrer');
} else { } else {
runNoticeAction(notice.cta.actionId, { navigate }); runNoticeAction(cta.actionId, { navigate });
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; if (cta.dismissOnAction !== false) handleDismissAll();
if (actionCta.dismissOnAction !== false) handleDismissAll();
} }
} }
function handleCTA() { runCta(notice?.cta); }
function handleSecondaryCTA() { runCta(notice?.secondaryCta); }
function animatedDismissAll() { function animatedDismissAll() {
const sheet = sheetRef.current; const sheet = sheetRef.current;
@@ -584,7 +626,7 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
notice, canPage, isLastPage, language, t, dur, ease, notice, canPage, isLastPage, language, t, dur, ease,
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef, touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef, stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll, announceIndex, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll,
handlePrev, handleNext, handleGoto, handlePrev, handleNext, handleGoto,
}; };
} }
@@ -593,7 +635,7 @@ type NoticeState = ReturnType<typeof useSystemNoticeModal>;
// Build the NoticeContent props for a given notice + pager slot index. // Build the NoticeContent props for a given notice + pager slot index.
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps { function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handlePrev, handleNext, handleGoto } = S; const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
const rawBody = t(n.bodyKey); const rawBody = t(n.bodyKey);
const body = n.bodyParams const body = n.bodyParams
? Object.entries(n.bodyParams).reduce( ? Object.entries(n.bodyParams).reduce(
@@ -606,12 +648,14 @@ function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number):
title: t(n.titleKey), title: t(n.titleKey),
body, body,
ctaLabel: n.cta ? t(n.cta.labelKey) : null, ctaLabel: n.cta ? t(n.cta.labelKey) : null,
secondaryCtaLabel: n.secondaryCta ? t(n.secondaryCta.labelKey) : null,
titleId: `notice-title-${n.id}`, titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`, bodyId: `notice-body-${n.id}`,
isDark, isDark,
onDismiss: handleDismiss, onDismiss: handleDismiss,
onDismissAll: handleDismissAll, onDismissAll: handleDismissAll,
onCTA: handleCTA, onCTA: handleCTA,
onSecondaryCTA: handleSecondaryCTA,
total: notices.length, total: notices.length,
currentPage: slotIdx, currentPage: slotIdx,
canPage, canPage,
+37 -5
View File
@@ -25,6 +25,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Draw the day's accommodation bookend legs (hotel → first stop, last stop → // 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. // hotel) unless the user turned the setting off — same gate as the sidebar.
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation) 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) => { const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort() if (routeAbortRef.current) routeAbortRef.current.abort()
@@ -105,8 +108,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// getDayBookendHotels returns the morning/evening hotel (they differ only on a // getDayBookendHotels returns the morning/evening hotel (they differ only on a
// transfer day) and already filters to accommodations that have coordinates. // transfer day) and already filters to accommodations that have coordinates.
const day = allDays.find(d => d.id === dayId) const day = allDays.find(d => d.id === dayId)
const { morning: startHotel, evening: endHotel } = const bookends = day && optimizeFromAccommodation !== false
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {} ? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: { lat: number; lng: number }[] = [] const flatPts: { lat: number; lng: number }[] = []
for (const e of entries) { for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng }) if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
@@ -114,7 +118,35 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
} }
const hotelPt = (a?: Accommodation) => const hotelPt = (a?: Accommodation) =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel)) // 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,
)
// Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to.
// Draw the hotel → hotel transfer directly. Gated on both bookends being real
// (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two
// hotels being distinct, so an ordinary same-hotel rest day still draws nothing.
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
const m = hotelPt(bookends?.morning)
const e = hotelPt(bookends?.evening)
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
}
const straightLines = (): [number, number][][] => const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number])) runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
@@ -146,7 +178,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines. // Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([]) if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
} }
}, [enabled, profile, accommodations, optimizeFromAccommodation]) }, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
// Stable signature for transport reservations on the selected day — changes when a transport // 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. // is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -170,7 +202,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId) updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation]) }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } 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'), ko: () => import('@trek/shared/i18n/ko'),
uk: () => import('@trek/shared/i18n/uk'), uk: () => import('@trek/shared/i18n/uk'),
gr: () => import('@trek/shared/i18n/gr'), 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 // 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; 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 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. */ onto the popup never steals the marker's mouseleave and causes flicker. */
.trek-map-popup { pointer-events: none; } .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; padding: 7px 10px;
border-radius: 10px; border-radius: 10px;
background: #fff; background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16); 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-top-color: #fff;
border-bottom-color: #fff; border-bottom-color: #fff;
border-left-color: #fff; border-left-color: #fff;
+73 -2
View File
@@ -4,9 +4,10 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server'; import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store'; 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 { useAuthStore } from '../store/authStore';
import { usePermissionsStore } from '../store/permissionsStore'; import { usePermissionsStore } from '../store/permissionsStore';
import { useSettingsStore } from '../store/settingsStore';
import DashboardPage from './DashboardPage'; import DashboardPage from './DashboardPage';
beforeEach(() => { 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', () => { describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => {
it('renders without error when dark_mode is set to auto', async () => { it('renders without error when dark_mode is set to auto', async () => {
// Seed settings with dark_mode = 'auto' to exercise the matchMedia branch // Seed settings with dark_mode = 'auto' to exercise the matchMedia branch
const { useSettingsStore } = await import('../store/settingsStore');
seedStore(useSettingsStore, { seedStore(useSettingsStore, {
settings: { settings: {
map_tile_url: '', map_tile_url: '',
@@ -812,6 +854,7 @@ describe('DashboardPage', () => {
default_currency: 'USD', default_currency: 'USD',
language: 'en', language: 'en',
temperature_unit: 'fahrenheit', temperature_unit: 'fahrenheit',
distance_unit: 'metric',
time_format: '12h', time_format: '12h',
show_place_description: false, show_place_description: false,
blur_booking_codes: false, blur_booking_codes: false,
@@ -831,4 +874,32 @@ describe('DashboardPage', () => {
expect(screen.getByText(/my trips/i)).toBeInTheDocument(); expect(screen.getByText(/my trips/i)).toBeInTheDocument();
}); });
}); });
describe('FE-PAGE-DASH-034: dashboard widgets persist to settings, not localStorage (#1311)', () => {
it('reads the timezone widget zones from the settings store', async () => {
// A zone that is NOT in the hardcoded default ([home, London, Tokyo]) — its presence
// proves the widget reads the stored preference rather than the old localStorage default.
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['America/New_York'] }), isLoaded: true });
render(<DashboardPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument());
expect(screen.getByText('New York')).toBeInTheDocument();
});
it('migrates the pre-3.1.3 localStorage prefs into settings and clears the legacy keys', async () => {
localStorage.setItem('trek_fx_from', 'CAD');
localStorage.setItem('trek_fx_to', 'CHF');
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<DashboardPage />);
// The one-time migration runs on mount (settings already loaded) and removes the keys.
await waitFor(() => {
expect(localStorage.getItem('trek_fx_from')).toBeNull();
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
});
const s = useSettingsStore.getState().settings;
expect(s.dashboard_fx_from).toBe('CAD');
expect(s.dashboard_fx_to).toBe('CHF');
expect(s.dashboard_timezones).toEqual(['America/New_York']);
});
});
}); });
+57 -16
View File
@@ -19,6 +19,7 @@ import {
LayoutGrid, List, Ticket, X, LayoutGrid, List, Ticket, X,
} from 'lucide-react' } from 'lucide-react'
import { formatTime, splitReservationDateTime } from '../utils/formatters' import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import '../styles/dashboard.css' import '../styles/dashboard.css'
@@ -358,12 +359,27 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
} }
// ── Atlas / stats row ──────────────────────────────────────────────────────── // ── 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 { function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const countries = stats?.countries || [] const countries = stats?.countries || []
const distanceKm = stats?.totalDistanceKm || 0 const distanceKm = stats?.totalDistanceKm || 0
const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm) const distance = convertDistance(distanceKm, distanceUnit)
const equatorTimes = (distanceKm / 40075).toFixed(2) const distanceText = formatCompactDistance(distance)
const equatorDistance = convertDistance(40075, distanceUnit)
const equatorTimes = (distance / equatorDistance).toFixed(2)
const distanceLabel = getDistanceUnitLabel(distanceUnit)
return ( return (
<section className="atlas"> <section className="atlas">
@@ -401,7 +417,7 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
<div className="atlas-card"> <div className="atlas-card">
<div className="label">{t('dashboard.atlas.distanceFlown')}</div> <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> <div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36"> <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" /> <circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
@@ -475,8 +491,12 @@ const FX_FALLBACK = ['EUR', 'USD', 'GBP', 'CHF', 'JPY', 'CAD', 'AUD', 'CNY', 'SE
function CurrencyTool(): React.ReactElement { function CurrencyTool(): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('trek_fx_from') || 'EUR') const isLoaded = useSettingsStore(s => s.isLoaded)
const [to, setTo] = useState(() => localStorage.getItem('trek_fx_to') || 'USD') const updateSetting = useSettingsStore(s => s.updateSetting)
const from = useSettingsStore(s => s.settings.dashboard_fx_from) || 'EUR'
const to = useSettingsStore(s => s.settings.dashboard_fx_to) || 'USD'
const setFrom = (v: string) => { updateSetting('dashboard_fx_from', v).catch(() => {}) }
const setTo = (v: string) => { updateSetting('dashboard_fx_to', v).catch(() => {}) }
const [amount, setAmount] = useState('100') const [amount, setAmount] = useState('100')
const [rates, setRates] = useState<Record<string, number> | null>(null) const [rates, setRates] = useState<Record<string, number> | null>(null)
@@ -494,7 +514,18 @@ function CurrencyTool(): React.ReactElement {
}, [from]) }, [from])
useEffect(() => { fetchRate() }, [fetchRate]) useEffect(() => { fetchRate() }, [fetchRate])
useEffect(() => { localStorage.setItem('trek_fx_from', from); localStorage.setItem('trek_fx_to', to) }, [from, to]) // One-time migration of the pre-3.1.3 localStorage values into the user's settings,
// so a (docker) upgrade no longer resets the widget (#1311).
useEffect(() => {
if (!isLoaded) return
const lf = localStorage.getItem('trek_fx_from')
const lt = localStorage.getItem('trek_fx_to')
if (!lf && !lt) return
if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {})
if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {})
localStorage.removeItem('trek_fx_from')
localStorage.removeItem('trek_fx_to')
}, [isLoaded, updateSetting])
const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK
const ccyOptions = currencies.map(c => ({ value: c, label: c })) const ccyOptions = currencies.map(c => ({ value: c, label: c }))
@@ -549,13 +580,12 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const home = Intl.DateTimeFormat().resolvedOptions().timeZone const home = Intl.DateTimeFormat().resolvedOptions().timeZone
const [now, setNow] = useState(() => new Date()) const [now, setNow] = useState(() => new Date())
const [zones, setZones] = useState<string[]>(() => { const isLoaded = useSettingsStore(s => s.isLoaded)
try { const updateSetting = useSettingsStore(s => s.updateSetting)
const raw = localStorage.getItem('trek_dashboard_tz') const stored = useSettingsStore(s => s.settings.dashboard_timezones)
if (raw) return JSON.parse(raw) // Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
} catch { /* ignore malformed storage */ } const zones = stored ?? [home, ...DEFAULT_ZONES]
return [home, ...DEFAULT_ZONES] const setZones = (next: string[]) => { updateSetting('dashboard_timezones', next).catch(() => {}) }
})
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
// A minute's resolution is plenty for clocks and keeps re-renders cheap. // A minute's resolution is plenty for clocks and keeps re-renders cheap.
@@ -564,7 +594,18 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
useEffect(() => { localStorage.setItem('trek_dashboard_tz', JSON.stringify(zones)) }, [zones]) // One-time migration of the pre-3.1.3 localStorage value into the user's settings,
// so a (docker) upgrade no longer resets the widget (#1311).
useEffect(() => {
if (!isLoaded) return
const raw = localStorage.getItem('trek_dashboard_tz')
if (!raw) return
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {})
} catch { /* ignore malformed storage */ }
localStorage.removeItem('trek_dashboard_tz')
}, [isLoaded, updateSetting])
const allZones = React.useMemo<string[]>(() => { const allZones = React.useMemo<string[]>(() => {
const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf
@@ -575,8 +616,8 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
.filter(z => !zones.includes(z)) .filter(z => !zones.includes(z))
.map(z => ({ value: z, label: z.replace(/_/g, ' '), searchLabel: z })) .map(z => ({ value: z, label: z.replace(/_/g, ' '), searchLabel: z }))
const addZone = (tz: string) => { if (tz) setZones(prev => prev.includes(tz) ? prev : [...prev, tz]); setAdding(false) } const addZone = (tz: string) => { if (tz && !zones.includes(tz)) setZones([...zones, tz]); setAdding(false) }
const removeZone = (tz: string) => setZones(prev => prev.filter(z => z !== tz)) const removeZone = (tz: string) => setZones(zones.filter(z => z !== tz))
const timeIn = (tz: string) => now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz }) const timeIn = (tz: string) => now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz })
const offsetLabel = (tz: string) => { const offsetLabel = (tz: string) => {
+30 -1
View File
@@ -3,7 +3,9 @@ import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server'; import { server } from '../../tests/helpers/msw/server';
import { resetAllStores } from '../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildSettings } from '../../tests/helpers/factories';
import { useSettingsStore } from '../store/settingsStore';
import SharedTripPage from './SharedTripPage'; import SharedTripPage from './SharedTripPage';
// Mock react-leaflet (SharedTripPage renders a map) // Mock react-leaflet (SharedTripPage renders a map)
@@ -480,4 +482,31 @@ describe('SharedTripPage', () => {
expect(screen.getByText(/LH2/)).toBeInTheDocument(); expect(screen.getByText(/LH2/)).toBeInTheDocument();
}); });
}); });
describe('FE-PAGE-SHARED-018: untitled day uses the translated day label (#1296)', () => {
it('renders the day-number label via i18n (German), not a hardcoded English string', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) });
const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: null, notes: null };
server.use(
http.get('/api/shared/:token', () => HttpResponse.json({
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
days: [day],
assignments: {},
dayNotes: {},
places: [],
reservations: [],
accommodations: [],
packing: [],
budget: [],
categories: [],
permissions: { share_bookings: false, share_packing: false, share_budget: false, share_collab: false },
collab: [],
})),
);
renderSharedTrip('test-token');
// The untitled day shows the German label "Tag 1", proving the hardcoded English
// "Day 1" was replaced by the i18n key t('dayplan.dayN').
await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument());
});
});
}); });
+1 -1
View File
@@ -196,7 +196,7 @@ export default function SharedTripPage() {
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}> style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
<div className={selectedDay === day.id ? 'bg-[#111827] text-white' : 'bg-[#f3f4f6] text-[#6b7280]'} style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div> <div className={selectedDay === day.id ? 'bg-[#111827] text-white' : 'bg-[#f3f4f6] text-[#6b7280]'} style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div className="text-[#111827]" style={{ fontSize: 14, fontWeight: 600 }}>{day.title || `Day ${day.day_number}`}</div> <div className="text-[#111827]" style={{ fontSize: 14, fontWeight: 600 }}>{day.title || t('dayplan.dayN', { n: day.day_number })}</div>
{day.date && <div className="text-[#9ca3af]" style={{ fontSize: 11, marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>} {day.date && <div className="text-[#9ca3af]" style={{ fontSize: 11, marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>}
</div> </div>
{dayAccs.map((acc: any) => ( {dayAccs.map((acc: any) => (
+2 -2
View File
@@ -5,7 +5,7 @@ import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore' import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto' 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 { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -211,7 +211,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
} = useTripPlanner() } = useTripPlanner()
const poi = usePoiExplore() 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 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 // Costs expense editor opened from a booking modal (save-then-open). Lives at the
+6
View File
@@ -30,6 +30,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
default_currency: 'USD', default_currency: 'USD',
language: localStorage.getItem('app_language') || 'en', language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit', temperature_unit: 'fahrenheit',
distance_unit: 'metric',
time_format: '12h', time_format: '12h',
show_place_description: false, show_place_description: false,
optimize_from_accommodation: true, optimize_from_accommodation: true,
@@ -37,8 +38,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
map_poi_pill_enabled: true, map_poi_pill_enabled: true,
mapbox_access_token: '', mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard', mapbox_style: 'mapbox://styles/mapbox/standard',
maplibre_style: '',
mapbox_3d_enabled: true, mapbox_3d_enabled: true,
mapbox_quality_mode: false, mapbox_quality_mode: false,
dashboard_fx_from: 'EUR',
dashboard_fx_to: 'USD',
// dashboard_timezones is intentionally left unset so the widget can tell "never
// chosen" (fall back to home + defaults) from an explicitly emptied list.
}, },
isLoaded: false, isLoaded: false,
+9 -1
View File
@@ -100,6 +100,8 @@ export interface TripFile {
url: string url: string
} }
export type DistanceUnit = 'metric' | 'imperial'
export interface Settings { export interface Settings {
map_tile_url: string map_tile_url: string
default_lat: number default_lat: number
@@ -109,17 +111,23 @@ export interface Settings {
default_currency: string default_currency: string
language: string language: string
temperature_unit: string temperature_unit: string
distance_unit?: DistanceUnit
time_format: string time_format: string
show_place_description: boolean show_place_description: boolean
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_booking_labels?: boolean map_booking_labels?: boolean
map_poi_pill_enabled?: boolean map_poi_pill_enabled?: boolean
optimize_from_accommodation?: boolean optimize_from_accommodation?: boolean
map_provider?: 'leaflet' | 'mapbox-gl' map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
mapbox_access_token?: string mapbox_access_token?: string
mapbox_style?: string mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean mapbox_quality_mode?: boolean
// Dashboard widget prefs — persisted server-side so a (docker) upgrade keeps them (#1311).
dashboard_fx_from?: string
dashboard_fx_to?: string
dashboard_timezones?: string[]
} }
export interface AssignmentsMap { export interface AssignmentsMap {
+36
View File
@@ -117,4 +117,40 @@ describe('getDayBookendHotels', () => {
const h = hotel({ place_lat: null, place_lng: null }) const h = hotel({ place_lat: null, place_lng: null })
expect(getDayBookendHotels(days[1], days, [h])).toEqual({}) 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, day: Day,
days: Day[], days: Day[],
accommodations: Accommodation[], accommodations: Accommodation[],
): { morning?: Accommodation; evening?: Accommodation } => { ): { morning?: Accommodation; evening?: Accommodation; morningIsSleptHere?: boolean; eveningIsOvernight?: boolean } => {
const inRange = accommodations.filter(a => const inRange = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null && a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days), isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
@@ -30,6 +30,13 @@ export const getDayBookendHotels = (
return { return {
morning: sleptHere ?? checkIn ?? inRange[0], morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? 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}`
}
@@ -251,6 +251,126 @@ describe('useRouteCalculation', () => {
expect(result.current.routeSegments).toEqual([]); 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-016: #1297 transfer day with no activities draws the hotel → hotel leg', async () => {
// Day 2 is a pure transfer: check out of hotel A (slept there last night) and into
// hotel B tonight, with no activities or transport. The map must still draw A → B.
const hotelA = { lat: 48.86, lng: 2.35 };
const hotelB = { lat: 45.76, lng: 4.84 };
const accommodations = [
{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotelA.lat, place_lng: hotelA.lng },
{ id: 2, start_day_id: 2, end_day_id: 3, place_lat: hotelB.lat, place_lng: hotelB.lng },
];
const store = { assignments: {} } as unknown as TripStoreState;
useTripStore.setState({
assignments: {},
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, 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([`${hotelA.lat},${hotelA.lng}`, `${hotelB.lat},${hotelB.lng}`]);
});
it('FE-HOOK-ROUTE-017: #1297 rest day in one hotel with no activities draws nothing', async () => {
// Guard against a zero-length loop: morning and evening hotel are the same, no
// activities — no transfer leg should be drawn.
const hotel = { lat: 48.86, lng: 2.35 };
const accommodations = [
{ id: 1, start_day_id: 1, end_day_id: 4, place_lat: hotel.lat, place_lng: hotel.lng },
];
const store = { assignments: {} } as unknown as TripStoreState;
useTripStore.setState({
assignments: {},
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }, { id: 3, day_number: 3 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 2, true, 'driving', accommodations as any)
);
await act(async () => {});
expect(result.current.route).toBeNull();
});
it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => { it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => {
const store = buildMockStore({}); const store = buildMockStore({});
const { result } = renderHook(() => const { result } = renderHook(() =>
+2 -1
View File
@@ -91,12 +91,13 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => { describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) 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: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' })) 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: 'ja', label: '日本語' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', 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: 'uk', label: 'Українська' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'sv', label: 'Svenska' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
}) })
}) })
+12
View File
@@ -63,6 +63,18 @@ export default defineConfig({
cacheableResponse: { statuses: [200] }, 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 // API calls — network only. We deliberately do NOT cache API
// responses in the Service Worker: Workbox keys entries by URL and // responses in the Service Worker: Workbox keys entries by URL and
+349 -8
View File
@@ -1,19 +1,20 @@
{ {
"name": "@trek/root", "name": "@trek/root",
"version": "3.1.2", "version": "3.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@trek/root", "name": "@trek/root",
"version": "3.1.2", "version": "3.1.3",
"workspaces": [ "workspaces": [
"client", "client",
"server", "server",
"shared" "shared"
], ],
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.3" "concurrently": "^10.0.3",
"unrun": "^0.3.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-linuxmusl-arm64": "0.35.1", "@img/sharp-linuxmusl-arm64": "0.35.1",
@@ -24,7 +25,7 @@
}, },
"client": { "client": {
"name": "@trek/client", "name": "@trek/client",
"version": "3.1.2", "version": "3.1.3",
"dependencies": { "dependencies": {
"@fontsource/geist-sans": "^5.2.5", "@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7", "@fontsource/poppins": "^5.2.7",
@@ -37,6 +38,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -84,11 +86,102 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^8.0.16", "vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
}, },
"client/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"client/node_modules/vite": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "~1.1.2",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
@@ -3918,6 +4011,119 @@
"node": ">=8" "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": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -6719,7 +6925,6 @@
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/hast": { "node_modules/@types/hast": {
@@ -9582,6 +9787,12 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/ecdsa-sig-formatter": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -11098,6 +11309,12 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT" "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": { "node_modules/glob": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -12568,6 +12785,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -12645,6 +12868,12 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -13266,6 +13495,40 @@
"test/build/typings" "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": { "node_modules/markdown-table": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -14529,6 +14792,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/mute-stream": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -15155,6 +15424,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -15461,6 +15742,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "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": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -15710,6 +15997,12 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -16038,6 +16331,12 @@
], ],
"license": "MIT" "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": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -16585,6 +16884,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "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": { "node_modules/restructure": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
@@ -18263,6 +18571,12 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/tinyrainbow": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
@@ -19071,6 +19385,33 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/until-async": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
@@ -20543,7 +20884,7 @@
}, },
"server": { "server": {
"name": "@trek/server", "name": "@trek/server",
"version": "3.1.2", "version": "3.1.3",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"@nestjs/common": "^11.1.24", "@nestjs/common": "^11.1.24",
@@ -20900,7 +21241,7 @@
}, },
"shared": { "shared": {
"name": "@trek/shared", "name": "@trek/shared",
"version": "3.1.2", "version": "3.1.3",
"dependencies": { "dependencies": {
"isomorphic-dompurify": "^3.15.0", "isomorphic-dompurify": "^3.15.0",
"zod": "^4.3.6" "zod": "^4.3.6"
+6 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "@trek/root", "name": "@trek/root",
"private": true, "private": true,
"version": "3.1.2", "version": "3.1.3",
"workspaces": [ "workspaces": [
"client", "client",
"server", "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" "format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
}, },
"devDependencies": { "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.", "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": { "overrides": {
@@ -34,9 +35,9 @@
"multer": "^2.2.0" "multer": "^2.2.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.62.0", "@img/sharp-linuxmusl-arm64": "0.35.1",
"@rollup/rollup-linux-arm64-musl": "4.62.0",
"@img/sharp-linuxmusl-x64": "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
View File
@@ -40,6 +40,9 @@ DEMO_MODE=false # Demo mode - resets data hourly
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) # MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) # MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000)
# Initial admin account — only used on first boot when no users exist yet. # Initial admin account — only used on first boot when no users exist yet.
# If both are set the admin account is created with these credentials. # If both are set the admin account is created with these credentials.
# If either is omitted a random password is generated and printed to the server log. # If either is omitted a random password is generated and printed to the server log.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/server", "name": "@trek/server",
"version": "3.1.2", "version": "3.1.3",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --require tsconfig-paths/register dist/index.js", "start": "node --require tsconfig-paths/register dist/index.js",
+17
View File
@@ -3054,6 +3054,23 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err; 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;
}
},
// Remember the app version a notice was dismissed at, so per-version recurring
// notices (e.g. the thank-you) re-appear on the next install/upgrade.
() => {
try {
db.exec('ALTER TABLE user_notice_dismissals ADD COLUMN dismissed_app_version TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+1
View File
@@ -138,6 +138,7 @@ function createTables(db: Database.Database): void {
notes TEXT, notes TEXT,
image_url TEXT, image_url TEXT,
google_place_id TEXT, google_place_id TEXT,
google_ftid TEXT,
website TEXT, website TEXT,
phone TEXT, phone TEXT,
transport_mode TEXT DEFAULT 'walking', 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. **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):** **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). 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\`. 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(); sessions.clear();
rateLimitMap.clear(); rateLimitMap.clear();
} }
+3 -2
View File
@@ -131,6 +131,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), 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_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")'), 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'), place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
@@ -147,7 +148,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, 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 (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); 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 }; if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try { try {
const run = db.transaction(() => { 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 }); 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 }; return { place, accommodation };
}); });
+11 -8
View File
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool( if (W) server.registerTool(
'create_place', '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: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
name: z.string().min(1).max(200), 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(), address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), 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_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'), 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(), notes: z.string().max(2000).optional(),
website: z.string().max(500).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, 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 (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); 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 }); safeBroadcast(tripId, 'place:created', { place });
return ok({ place }); return ok({ place });
} }
@@ -66,6 +67,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), 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_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")'), 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'), place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
@@ -76,14 +78,14 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, 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 (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try { try {
const run = db.transaction(() => { 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); const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment }; 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(), transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), 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_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, 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 (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); 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 }; if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:updated', { place }); safeBroadcast(tripId, 'place:updated', { place });
return ok({ place }); return ok({ place });
@@ -196,7 +199,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (R) server.registerTool( if (R) server.registerTool(
'search_place', '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: { inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'), query: z.string().min(1).max(500).describe('Place name or address to search for'),
}, },
+2 -1
View File
@@ -114,7 +114,8 @@ export function applyGlobalMiddleware(
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", "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:"], workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"], childSrc: ["'self'", "blob:"],
+4 -4
View File
@@ -136,7 +136,7 @@ export class BudgetController {
} }
@Post() @Post()
create( async create(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('tripId') tripId: string, @Param('tripId') tripId: string,
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number }, @Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number },
@@ -147,7 +147,7 @@ export class BudgetController {
if (!body.name) { if (!body.name) {
throw new HttpException({ error: 'Name is required' }, 400); throw new HttpException({ error: 'Name is required' }, 400);
} }
const item = this.budget.create(tripId, body as { name: string }); const item = await this.budget.create(tripId, body as { name: string });
this.budget.broadcast(tripId, 'budget:created', { item }, socketId); this.budget.broadcast(tripId, 'budget:created', { item }, socketId);
return { item }; return { item };
} }
@@ -181,7 +181,7 @@ export class BudgetController {
} }
@Put(':id') @Put(':id')
update( async update(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('tripId') tripId: string, @Param('tripId') tripId: string,
@Param('id') id: string, @Param('id') id: string,
@@ -190,7 +190,7 @@ export class BudgetController {
) { ) {
const trip = this.requireTrip(tripId, user); const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user); this.requireEdit(trip, user);
const updated = this.budget.update(id, tripId, body); const updated = await this.budget.update(id, tripId, body);
if (!updated) { if (!updated) {
throw new HttpException({ error: 'Budget item not found' }, 404); throw new HttpException({ error: 'Budget item not found' }, 404);
} }
+30 -2
View File
@@ -41,11 +41,39 @@ export class BudgetService {
return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
} }
create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) { // Freeze the live FX rate at entry time into budget_items.exchange_rate so a settled
// position isn't re-opened when live rates drift later (#1335). Only for a foreign
// currency with no explicit rate; degrades to live rates if the fetch fails. On update
// it (re)freezes only when the currency changes, so an unrelated edit never moves money.
private async freezeForeignRate(
tripId: string,
data: { currency?: string | null; exchange_rate?: number },
existingItemId?: string,
): Promise<void> {
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
const cur = (data.currency || '').toUpperCase();
if (!cur) return; // currency not being set in this request
if (existingItemId != null) {
const existing = db.prepare('SELECT currency FROM budget_items WHERE id = ?')
.get(existingItemId) as { currency?: string } | undefined;
if (existing && (existing.currency || '').toUpperCase() === cur) return; // currency unchanged
}
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
.get(tripId) as { currency?: string } | undefined;
const tripCur = (trip?.currency || 'EUR').toUpperCase();
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
const rates = await getRates(tripCur);
const r = rates?.[cur];
if (r && r > 0) data.exchange_rate = r;
}
async create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
await this.freezeForeignRate(tripId, data);
return svc.createBudgetItem(tripId, data); return svc.createBudgetItem(tripId, data);
} }
update(id: string, tripId: string, data: Parameters<typeof svc.updateBudgetItem>[2]) { async update(id: string, tripId: string, data: Parameters<typeof svc.updateBudgetItem>[2]) {
await this.freezeForeignRate(tripId, data, id);
return svc.updateBudgetItem(id, tripId, data); return svc.updateBudgetItem(id, tripId, data);
} }
+12
View File
@@ -72,6 +72,15 @@ export class FilesController {
return trip; return trip;
} }
// A file may only point at reservations/assignments/places from its own trip.
// Reject cross-trip ids before they are stored — the reservation JOIN would
// otherwise leak the foreign reservation's title back to the caller.
private assertLinkTargets(tripId: string, body: { reservation_id?: string | null; assignment_id?: string | null; place_id?: string | null }) {
if (this.files.findForeignLinkTarget(tripId, body)) {
throw new HttpException({ error: 'Linked item does not belong to this trip' }, 400);
}
}
@Get() @Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('trash') trash?: string) { list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('trash') trash?: string) {
this.requireTrip(tripId, user); this.requireTrip(tripId, user);
@@ -97,6 +106,7 @@ export class FilesController {
if (!file) { if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400); throw new HttpException({ error: 'No file uploaded' }, 400);
} }
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
const created = this.files.createFile(tripId, file, user.id, { const created = this.files.createFile(tripId, file, user.id, {
place_id: body.place_id, place_id: body.place_id,
description: body.description, description: body.description,
@@ -116,6 +126,7 @@ export class FilesController {
if (!file) { if (!file) {
throw new HttpException({ error: 'File not found' }, 404); throw new HttpException({ error: 'File not found' }, 404);
} }
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
const updated = this.files.updateFile(id, file, { description: body.description, place_id: body.place_id, reservation_id: body.reservation_id }); const updated = this.files.updateFile(id, file, { description: body.description, place_id: body.place_id, reservation_id: body.reservation_id });
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId); this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
return { file: updated }; return { file: updated };
@@ -203,6 +214,7 @@ export class FilesController {
if (!file) { if (!file) {
throw new HttpException({ error: 'File not found' }, 404); throw new HttpException({ error: 'File not found' }, 404);
} }
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id });
const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id }); const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id });
return { success: true, links }; return { success: true, links };
} }
+1
View File
@@ -43,6 +43,7 @@ export class FilesService {
restoreFile(id: string) { return svc.restoreFile(id); } restoreFile(id: string) { return svc.restoreFile(id); }
permanentDeleteFile(file: TripFile) { return svc.permanentDeleteFile(file); } permanentDeleteFile(file: TripFile) { return svc.permanentDeleteFile(file); }
emptyTrash(tripId: string) { return svc.emptyTrash(tripId); } emptyTrash(tripId: string) { return svc.emptyTrash(tripId); }
findForeignLinkTarget(tripId: string, opts: Parameters<typeof svc.findForeignLinkTarget>[1]) { return svc.findForeignLinkTarget(tripId, opts); }
createFileLink(id: string, opts: Parameters<typeof svc.createFileLink>[1]) { return svc.createFileLink(id, opts); } createFileLink(id: string, opts: Parameters<typeof svc.createFileLink>[1]) { return svc.createFileLink(id, opts); }
deleteFileLink(linkId: string, id: string) { return svc.deleteFileLink(linkId, id); } deleteFileLink(linkId: string, id: string) { return svc.deleteFileLink(linkId, id); }
getFileLinks(id: string) { return svc.getFileLinks(id); } getFileLinks(id: string) { return svc.getFileLinks(id); }
+15 -2
View File
@@ -15,6 +15,15 @@ export function entityCode(e: AirtrailNamedCode | null | undefined): string | nu
return e?.icao || e?.iata || null; return e?.icao || e?.iata || null;
} }
/**
* Human-readable name for an airline/aircraft (e.g. "Lufthansa"), falling back to the
* code when AirTrail doesn't provide a name. Used for what TREK displays/stores; the
* raw code stays available via entityCode for the writeback payload (#1334).
*/
export function entityName(e: AirtrailNamedCode | null | undefined): string | null {
return e?.name || e?.icao || e?.iata || null;
}
/** /**
* Local calendar date + clock time for an instant at a given IANA zone. * Local calendar date + clock time for an instant at a given IANA zone.
* AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local * AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local
@@ -57,7 +66,7 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
date: raw.date ?? null, date: raw.date ?? null,
departure: raw.departureScheduled ?? null, departure: raw.departureScheduled ?? null,
arrival: raw.arrivalScheduled ?? null, arrival: raw.arrivalScheduled ?? null,
airline: entityCode(raw.airline), airline: entityName(raw.airline),
flightNumber: raw.flightNumber ?? null, flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft), aircraft: entityCode(raw.aircraft),
seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null, seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
@@ -142,10 +151,14 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio
} }
const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0]; const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0];
const airlineName = entityName(raw.airline);
const airlineCode = entityCode(raw.airline); const airlineCode = entityCode(raw.airline);
const aircraftCode = entityCode(raw.aircraft); const aircraftCode = entityCode(raw.aircraft);
const metadata: Record<string, unknown> = {}; const metadata: Record<string, unknown> = {};
if (airlineCode) metadata.airline = airlineCode; // Display the airline name; keep the code in airline_code for the AirTrail writeback,
// which expects a code, not a name (#1334 / #1240).
if (airlineName) metadata.airline = airlineName;
if (airlineCode) metadata.airline_code = airlineCode;
if (raw.flightNumber) metadata.flight_number = raw.flightNumber; if (raw.flightNumber) metadata.flight_number = raw.flightNumber;
if (aircraftCode) metadata.aircraft = aircraftCode; if (aircraftCode) metadata.aircraft = aircraftCode;
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg; if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
+3 -2
View File
@@ -216,9 +216,10 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw):
arrivalScheduledTime: arr.time, arrivalScheduledTime: arr.time,
// These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK // These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK
// edit can leave them out of `metadata`. Preserve AirTrail's current value when // edit can leave them out of `metadata`. Preserve AirTrail's current value when
// TREK has none rather than nulling it out (#1240). entityCode mirrors the // TREK has none rather than nulling it out (#1240). Use airline_code (not the
// display name in metadata.airline, #1334); both it and entityCode mirror the
// import/hash code-selection so a writeback stays a no-op for the hash. // import/hash code-selection so a writeback stays a no-op for the hash.
airline: meta.airline ?? entityCode(existing.airline) ?? null, airline: meta.airline_code ?? entityCode(existing.airline) ?? null,
flightNumber: meta.flight_number ?? existing.flightNumber ?? null, flightNumber: meta.flight_number ?? existing.flightNumber ?? null,
aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null, aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null,
aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null, aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null,
+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_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, 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 c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
@@ -59,6 +59,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
image_url: a.image_url, image_url: a.image_url,
transport_mode: a.transport_mode, transport_mode: a.transport_mode,
google_place_id: a.google_place_id, google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website, website: a.website,
phone: a.phone, phone: a.phone,
category: a.category_id ? { 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_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, 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 c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
+66 -11
View File
@@ -199,19 +199,74 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise<s
} }
} }
export function getCountryFromCoords(lat: number, lng: number): string | null { // ── Point-in-polygon over the bundled admin0 borders (#1331) ─────────────────
let bestCode: string | null = null;
let bestArea = Infinity; // Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring.
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { function pointInRing(lng: number, lat: number, ring: number[][]): boolean {
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { let inside = false;
const area = (maxLng - minLng) * (maxLat - minLat); for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
if (area < bestArea) { const xi = ring[i][0], yi = ring[i][1];
bestArea = area; const xj = ring[j][0], yj = ring[j][1];
bestCode = code; if (((yi > lat) !== (yj > lat)) && (lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi)) {
} inside = !inside;
} }
} }
return bestCode; return inside;
}
// True when (lng,lat) falls inside a Polygon/MultiPolygon, honouring holes.
function pointInGeometry(lng: number, lat: number, geom: { type: string; coordinates: number[][][] | number[][][][] }): boolean {
const polygons = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates) as number[][][][];
for (const poly of polygons) {
if (!pointInRing(lng, lat, poly[0])) continue;
let inHole = false;
for (let h = 1; h < poly.length; h++) {
if (pointInRing(lng, lat, poly[h])) { inHole = true; break; }
}
if (!inHole) return true;
}
return false;
}
// ISO_A2 → admin0 geometry, built once. Micro-territories (HK, MO, SM, VA, …) aren't
// in admin0, so they stay absent and keep the smallest-box behaviour below.
let countryPolyIndex: Map<string, { type: string; coordinates: number[][][] | number[][][][] }> | null = null;
function getCountryPolyIndex(): Map<string, { type: string; coordinates: number[][][] | number[][][][] }> {
if (countryPolyIndex) return countryPolyIndex;
const idx = new Map<string, { type: string; coordinates: number[][][] | number[][][][] }>();
for (const f of loadGeoBundle('admin0').features ?? []) {
const code = f.properties?.ISO_A2;
if (code && code !== '-99' && f.geometry) idx.set(String(code).toUpperCase(), f.geometry);
}
countryPolyIndex = idx;
return idx;
}
export function getCountryFromCoords(lat: number, lng: number): string | null {
// Cheap prefilter: every country whose bounding box contains the point.
const candidates: { code: string; area: number }[] = [];
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
candidates.push({ code, area: (maxLng - minLng) * (maxLat - minLat) });
}
}
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0].code;
// Boxes overlap near borders, so a single point can sit in several — picking the
// smallest box then mis-assigns a point just across the border (#1331). Disambiguate
// with the real admin0 polygon: try candidates smallest-box-first and return the one
// whose polygon actually contains the point. A candidate with no admin0 polygon (a
// micro-territory like HK/MO/SM/VA) keeps the smallest-box win.
candidates.sort((a, b) => a.area - b.area);
const polys = getCountryPolyIndex();
for (const { code } of candidates) {
const poly = polys.get(code);
if (!poly) return code;
if (pointInGeometry(lng, lat, poly)) return code;
}
// No polygon contained the point (coastal slop / data gap) — fall back to smallest box.
return candidates[0].code;
} }
export function getCountryFromAddress(address: string | null): string | null { export function getCountryFromAddress(address: string | null): string | null {
+12 -4
View File
@@ -349,9 +349,17 @@ export function calculateSettlement(
const rates = opts.rates ?? null; const rates = opts.rates ?? null;
// Amount in some currency → base. Pre-rework rows store currency = NULL, which // Amount in some currency → base. Pre-rework rows store currency = NULL, which
// means "the trip's own currency". rates[X] = units of X per 1 base. // means "the trip's own currency". rates[X] = units of X per 1 base.
const toBase = (amount: number, itemCurrency: string | null | undefined): number => { const toBase = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => {
const cur = (itemCurrency || tripCurrency).toUpperCase(); const cur = (itemCurrency || tripCurrency).toUpperCase();
if (cur === base || !rates) return amount; if (cur === base) return amount;
// Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate it
// was booked at, so a later live-rate drift doesn't re-open it with a few-cent residual.
// The stored rate is units of item-currency per 1 trip-currency, so it only applies when
// converting to the trip's own currency; otherwise (and for legacy rows) use live rates.
if (base === tripCurrency && itemRate != null && itemRate > 0 && itemRate !== 1) {
return amount / itemRate;
}
if (!rates) return amount;
const r = rates[cur]; const r = rates[cur];
return r && r > 0 ? amount / r : amount; return r && r > 0 ? amount / r : amount;
}; };
@@ -384,11 +392,11 @@ export function calculateSettlement(
const payers = allPayers.filter(p => p.budget_item_id === item.id); const payers = allPayers.filter(p => p.budget_item_id === item.id);
if (members.length === 0) continue; // planning-only entry → doesn't affect balances if (members.length === 0) continue; // planning-only entry → doesn't affect balances
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0); const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate), 0);
const sharePerMember = paidBase / members.length; const sharePerMember = paidBase / members.length;
// Payers are credited what they actually paid (converted to base)… // Payers are credited what they actually paid (converted to base)…
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency); for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
// …and every split participant owes an equal share of the base total. // …and every split participant owes an equal share of the base total.
for (const m of members) ensure(m.user_id, m).balance -= sharePerMember; for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
} }
+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_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, 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 c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
@@ -54,6 +54,7 @@ export function getAssignmentsForDay(dayId: number | string) {
image_url: a.image_url, image_url: a.image_url,
transport_mode: a.transport_mode, transport_mode: a.transport_mode,
google_place_id: a.google_place_id, google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website, website: a.website,
phone: a.phone, phone: a.phone,
category: a.category_id ? { 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_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, 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 c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
+28
View File
@@ -55,6 +55,34 @@ export function formatFile(file: TripFile & { trip_id?: number; uploaded_by_avat
}; };
} }
// ---------------------------------------------------------------------------
// Trip-scoped link validation
// ---------------------------------------------------------------------------
/**
* A file, and any reservation / day-assignment / place it points at, must all
* live in the same trip. FILE_SELECT and getFileLinks join the reservation and
* return its title, so without this guard a member of trip A could aim a file
* (or a file_link) at trip B's reservation id and read the title back. Returns
* the first field that escapes `tripId`, or null when every supplied id belongs
* to the trip. Absent / null / zero ids are ignored (they clear the link).
*/
export function findForeignLinkTarget(
tripId: string | number,
opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null }
): 'reservation_id' | 'assignment_id' | 'place_id' | null {
if (opts.reservation_id && !db.prepare('SELECT 1 FROM reservations WHERE id = ? AND trip_id = ?').get(opts.reservation_id, tripId)) {
return 'reservation_id';
}
if (opts.place_id && !db.prepare('SELECT 1 FROM places WHERE id = ? AND trip_id = ?').get(opts.place_id, tripId)) {
return 'place_id';
}
if (opts.assignment_id && !db.prepare('SELECT 1 FROM day_assignments a JOIN days d ON a.day_id = d.id WHERE a.id = ? AND d.trip_id = ?').get(opts.assignment_id, tripId)) {
return 'assignment_id';
}
return null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// File path resolution & validation // File path resolution & validation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+73 -11
View File
@@ -45,6 +45,7 @@ interface GooglePlaceResult {
websiteUri?: string; websiteUri?: string;
nationalPhoneNumber?: string; nationalPhoneNumber?: string;
types?: string[]; types?: string[];
googleMapsUri?: string;
} }
interface GoogleAutocompleteSuggestion { interface GoogleAutocompleteSuggestion {
@@ -60,7 +61,6 @@ interface GoogleAutocompleteSuggestion {
interface GooglePlaceDetails extends GooglePlaceResult { interface GooglePlaceDetails extends GooglePlaceResult {
userRatingCount?: number; userRatingCount?: number;
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean }; regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
googleMapsUri?: string;
editorialSummary?: { text: string }; editorialSummary?: { text: string };
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[]; reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[]; photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
@@ -68,7 +68,17 @@ interface GooglePlaceDetails extends GooglePlaceResult {
// ── Constants ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)'; // Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that
// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited
// and throttled harder (see #1309). When the instance URL is configured we append it;
// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it.
export function buildUserAgent(instanceUrl: string | undefined): string {
const base = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`;
return base;
}
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
const UA = buildUserAgent(getAppUrl());
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a // TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is // couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
@@ -88,6 +98,23 @@ function toApiLang(lang: string | undefined, fallback = 'en'): string {
return API_LANG_OVERRIDES[code] ?? code; 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) ──────────────────────────────────────────────── // ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache'; import * as placePhotoCache from './placePhotoCache';
@@ -145,6 +172,7 @@ export async function searchNominatim(query: string, lang?: string) {
const data = await response.json() as NominatimResult[]; const data = await response.json() as NominatimResult[];
return data.map(item => ({ return data.map(item => ({
google_place_id: null, google_place_id: null,
google_ftid: null,
osm_id: `${item.osm_type}:${item.osm_id}`, osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '', name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '', address: item.display_name || '',
@@ -264,15 +292,39 @@ interface PoiSearchResult {
// frequently overloaded (504s) and some community mirrors are unreachable from // frequently overloaded (504s) and some community mirrors are unreachable from
// certain networks. Racing them means whichever mirror is fastest-reachable for // certain networks. Racing them means whichever mirror is fastest-reachable for
// this user answers, and an overloaded or blocked one never blocks the others. // this user answers, and an overloaded or blocked one never blocks the others.
const OVERPASS_MIRRORS = [ const DEFAULT_OVERPASS_MIRRORS = [
'https://overpass-api.de/api/interpreter', 'https://overpass-api.de/api/interpreter',
'https://maps.mail.ru/osm/tools/overpass/api/interpreter', 'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
'https://overpass.kumi.systems/api/interpreter', 'https://overpass.kumi.systems/api/interpreter',
'https://overpass.private.coffee/api/interpreter', 'https://overpass.private.coffee/api/interpreter',
]; ];
// Per-mirror cap. Because mirrors race in parallel this is also the worst-case
// total wait before every mirror is given up on and a 502 is returned. // Operators behind locked-down egress — or running their own Overpass — can point TREK
const OVERPASS_TIMEOUT_MS = 12000; // at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it
// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a
// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped.
export function resolveOverpassEndpoints(raw: string | undefined = process.env.OVERPASS_URL): string[] {
const custom = (raw ?? '')
.split(',')
.map(s => s.trim())
.filter(s => {
try { const u = new URL(s); return u.protocol === 'http:' || u.protocol === 'https:'; }
catch { return false; }
});
return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS;
}
const OVERPASS_MIRRORS = resolveOverpassEndpoints();
// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case
// wait before every mirror is given up on and a 502 is returned. Public mirrors answer
// in 12s when reachable, so the cap mainly bounds dead/blocked ones; operators with a
// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or
// non-numeric value falls back to the default — a 0/negative cap would abort every
// request immediately and 502 the search.
export function resolveOverpassTimeoutMs(raw: string | undefined = process.env.OVERPASS_TIMEOUT_MS): number {
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : 12000;
}
const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs();
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes // Largest viewport side we send to Overpass. A country/continent-sized bbox makes
// Overpass scan millions of elements and time out; clamping to a centred window // Overpass scan millions of elements and time out; clamping to a centred window
// keeps the query cheap so the explore pill returns fast at ANY zoom level. // keeps the query cheap so the explore pill returns fast at ANY zoom level.
@@ -324,8 +376,15 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
// Promise.any resolves with the first mirror to return valid JSON, and only // Promise.any resolves with the first mirror to return valid JSON, and only
// rejects (AggregateError) once every mirror has failed. // rejects (AggregateError) once every mirror has failed.
return await Promise.any(OVERPASS_MIRRORS.map(attempt)); return await Promise.any(OVERPASS_MIRRORS.map(attempt));
} catch { } catch (err) {
throw Object.assign(new Error('Overpass request failed'), { status: 502 }); // Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM
// body, …) so an operator can tell blocked egress / a firewall from a transiently
// overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309).
const reasons = err instanceof AggregateError
? err.errors.map(e => (e instanceof Error ? e.message : String(e))).join(' | ')
: (err instanceof Error ? err.message : String(err));
console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`);
throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 });
} finally { } finally {
// Cancel the slower/losing requests — we already have (or have given up on) a result. // Cancel the slower/losing requests — we already have (or have given up on) a result.
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } }); controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
@@ -573,7 +632,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey, '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), body: JSON.stringify(searchBody),
}); });
@@ -588,6 +647,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
const places = (data.places || []).map((p: GooglePlaceResult) => ({ const places = (data.places || []).map((p: GooglePlaceResult) => ({
google_place_id: p.id, google_place_id: p.id,
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
name: p.displayName?.text || '', name: p.displayName?.text || '',
address: p.formattedAddress || '', address: p.formattedAddress || '',
lat: p.location?.latitude || null, lat: p.location?.latitude || null,
@@ -740,6 +800,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
const place = { const place = {
google_place_id: data.id, google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '', name: data.displayName?.text || '',
address: data.formattedAddress || '', address: data.formattedAddress || '',
lat: data.location?.latitude || null, lat: data.location?.latitude || null,
@@ -799,6 +860,7 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l
const place = { const place = {
google_place_id: data.id, google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '', name: data.displayName?.text || '',
address: data.formattedAddress || '', address: data.formattedAddress || '',
lat: data.location?.latitude || null, lat: data.location?.latitude || null,
@@ -983,7 +1045,7 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
// ── Resolve Google Maps URL ────────────────────────────────────────────────── // ── 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; let resolvedUrl = url;
// Extract coordinates from a string (URL or page body). Google Maps encodes // Extract coordinates from a string (URL or page body). Google Maps encodes
@@ -1064,5 +1126,5 @@ export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number;
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null; const address = nominatim.display_name || null;
return { lat, lng, name, address }; return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) };
} }
+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, * 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 * 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 * 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 * and persist the resolved `google_place_id` plus `google_ftid` (which power
* opening hours / the proper Maps link going forward). * on-demand opening hours and proper Maps links going forward).
* *
* This runs detached from the import request (fire-and-forget) so a long list * 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 * never blocks the response, and pushes each enriched row over the websocket so
@@ -26,6 +26,7 @@ export interface EnrichablePlace {
lat: number; lat: number;
lng: number; lng: number;
google_place_id?: string | null; google_place_id?: string | null;
google_ftid?: string | null;
address?: string | null; address?: string | null;
website?: string | null; website?: string | null;
phone?: 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); const gpid = str(match.google_place_id);
if (!gpid) return; if (!gpid) return;
const gftid = str(match.google_ftid);
// COALESCE so enrichment only fills empty columns — never overwrites data the // COALESCE so enrichment only fills empty columns — never overwrites data the
// import already captured (e.g. Naver's address) or anything the user edited. // import already captured (e.g. Naver's address) or anything the user edited.
db.prepare( db.prepare(
`UPDATE places `UPDATE places
SET google_place_id = COALESCE(google_place_id, ?), SET google_place_id = COALESCE(google_place_id, ?),
address = COALESCE(address, ?), google_ftid = COALESCE(google_ftid, ?),
website = COALESCE(website, ?), address = COALESCE(address, ?),
phone = COALESCE(phone, ?), website = COALESCE(website, ?),
updated_at = CURRENT_TIMESTAMP phone = COALESCE(phone, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND trip_id = ?`, 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 // 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. // that case — a missing photo must never abort the rest of the enrichment.
+83 -12
View File
@@ -123,27 +123,27 @@ export function createPlace(
category_id?: number; price?: number; currency?: string; category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string; place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: 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[]; transport_mode?: string; tags?: number[];
}, },
) { ) {
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, 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 = [], transport_mode, tags = [],
} = body; } = body;
const result = db.prepare(` const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode) duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, name, description || null, lat || null, lng || null, address || null, tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null, category_id || null, price || null, currency || null,
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || 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; const placeId = result.lastInsertRowid;
@@ -180,7 +180,7 @@ export function updatePlace(
category_id?: number; price?: number; currency?: string; category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string; place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: 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[]; transport_mode?: string; tags?: number[];
}, },
) { ) {
@@ -190,7 +190,7 @@ export function updatePlace(
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, 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, transport_mode, tags,
} = body; } = body;
@@ -210,6 +210,7 @@ export function updatePlace(
notes = ?, notes = ?,
image_url = ?, image_url = ?,
google_place_id = ?, google_place_id = ?,
google_ftid = ?,
osm_id = ?, osm_id = ?,
website = ?, website = ?,
phone = ?, phone = ?,
@@ -231,6 +232,7 @@ export function updatePlace(
notes !== undefined ? notes : existingPlace.notes, notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url, image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, 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, osm_id !== undefined ? osm_id : existingPlace.osm_id,
website !== undefined ? website : existingPlace.website, website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone, phone !== undefined ? phone : existingPlace.phone,
@@ -625,6 +627,65 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
// Import Google Maps list // 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) { export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
let listId: string | null = null; let listId: string | null = null;
let resolvedUrl = url; let resolvedUrl = url;
@@ -658,6 +719,11 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
} }
if (!listId) { if (!listId) {
// A single-place share link (…/maps/place/…) carries no list id — point the user at
// the place search box instead of a cryptic "could not extract list ID" (#1304).
if (resolvedUrl.includes('/maps/place/')) {
return { error: 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', status: 400 };
}
return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 }; return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 };
} }
@@ -689,7 +755,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
} }
// Parse place data from items // 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) { for (const item of items) {
const coords = item?.[1]?.[5]; const coords = item?.[1]?.[5];
const lat = coords?.[2]; const lat = coords?.[2];
@@ -698,7 +764,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
const note = item?.[3] || null; const note = item?.[3] || null;
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { 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 +774,23 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
const dedup = buildDedupSet(tripId); const dedup = buildDedupSet(tripId);
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode) INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking') VALUES (?, ?, ?, ?, ?, ?, 'walking')
`); `);
const updateGoogleFtidStmt = db.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
const created: any[] = []; const created: any[] = [];
let skipped = 0; let skipped = 0;
const insertAll = db.transaction(() => { const insertAll = db.transaction(() => {
for (const p of places) { for (const p of places) {
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { 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++; skipped++;
continue; 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)); const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place); created.push(place);
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); 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, image_url: a.image_url,
transport_mode: a.transport_mode, transport_mode: a.transport_mode,
google_place_id: a.google_place_id, google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website, website: a.website,
phone: a.phone, phone: a.phone,
category: a.category_id ? { category: a.category_id ? {
+28
View File
@@ -59,6 +59,34 @@ function resolveDayIdFromTime(
return row?.id ?? null; return row?.id ?? null;
} }
// After a trip's date range changes, generateDays positionally re-dates the day rows
// (keeping their ids), so a dated booking's day_id stays glued to a now-re-dated day and
// the booking visually shifts by the offset (#1288). Re-anchor non-hotel bookings to the
// day matching their absolute reservation_time — the same derivation create/updateReservation
// use. Only updates when a matching day exists, so a booking whose date now falls outside
// the new range is left untouched. Hotels keep their range on the linked day_accommodation.
export function resyncReservationDays(tripId: string | number): void {
const rows = db.prepare(
`SELECT id, reservation_time, reservation_end_time, day_id, end_day_id
FROM reservations
WHERE trip_id = ? AND type != 'hotel' AND reservation_time IS NOT NULL`,
).all(tripId) as {
id: number; reservation_time: string | null; reservation_end_time: string | null;
day_id: number | null; end_day_id: number | null;
}[];
const update = db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?');
for (const r of rows) {
const newDayId = resolveDayIdFromTime(tripId, r.reservation_time);
if (newDayId == null) continue;
const newEndDayId = r.reservation_end_time
? (resolveDayIdFromTime(tripId, r.reservation_end_time) ?? r.end_day_id)
: r.end_day_id;
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
update.run(newDayId, newEndDayId, r.id);
}
}
}
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void { function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
// Bind the transaction lazily on each call. Binding at module load time // Bind the transaction lazily on each call. Binding at module load time
// captures the DB connection that was open then, which becomes invalid // captures the DB connection that was open then, which becomes invalid
+6 -3
View File
@@ -8,6 +8,7 @@ const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
export const DEFAULTABLE_USER_SETTING_KEYS = [ export const DEFAULTABLE_USER_SETTING_KEYS = [
'temperature_unit', 'temperature_unit',
'distance_unit',
'dark_mode', 'dark_mode',
'time_format', 'time_format',
// Instance-wide default currency for Costs (new users inherit it until they // Instance-wide default currency for Costs (new users inherit it until they
@@ -15,11 +16,12 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'default_currency', 'default_currency',
'blur_booking_codes', 'blur_booking_codes',
'map_tile_url', 'map_tile_url',
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the // Instance-wide GL map defaults: admins can set Mapbox token/style or
// whole instance uses Mapbox without each user pasting their own key (#920). // tokenless MapLibre/OpenFreeMap style defaults for new users (#920).
'map_provider', 'map_provider',
'mapbox_access_token', 'mapbox_access_token',
'mapbox_style', 'mapbox_style',
'maplibre_style',
'mapbox_3d_enabled', 'mapbox_3d_enabled',
'mapbox_quality_mode', 'mapbox_quality_mode',
] as const; ] as const;
@@ -28,9 +30,10 @@ type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = { const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
temperature_unit: ['fahrenheit', 'celsius'], temperature_unit: ['fahrenheit', 'celsius'],
distance_unit: ['metric', 'imperial'],
time_format: ['12h', '24h'], time_format: ['12h', '24h'],
dark_mode: [true, false, 'light', 'dark', 'auto'], 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']); const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
+9 -5
View File
@@ -5,7 +5,7 @@ import { Trip, User } from '../types';
import { listDays, listAccommodations } from './dayService'; import { listDays, listAccommodations } from './dayService';
import { listBudgetItems } from './budgetService'; import { listBudgetItems } from './budgetService';
import { listItems as listPackingItems } from './packingService'; import { listItems as listPackingItems } from './packingService';
import { listReservations, loadEndpointsByTrip } from './reservationService'; import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService';
import { listNotes as listCollabNotes } from './collabService'; import { listNotes as listCollabNotes } from './collabService';
import { shiftOwnerEntriesForTripWindow } from './vacayService'; import { shiftOwnerEntriesForTripWindow } from './vacayService';
@@ -256,8 +256,12 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined; const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) {
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount); generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
// generateDays re-dates day rows positionally; re-anchor dated bookings to the day
// matching their absolute reservation_time so they don't shift with it (#1288).
resyncReservationDays(tripId);
}
const changes: Record<string, unknown> = {}; const changes: Record<string, unknown> = {};
if (title && title !== trip.title) changes.title = title; if (title && title !== trip.title) changes.title = title;
@@ -632,14 +636,14 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
const insertPlace = db.prepare(` const insertPlace = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, end_time, reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id) duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
for (const p of oldPlaces) { for (const p of oldPlaces) {
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, 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.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.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); placeMap.set(p.id, r.lastInsertRowid);
} }
+45 -133
View File
@@ -11,128 +11,65 @@ registerPredicate('whitespace-collision-detected', () => {
* SYSTEM NOTICE REGISTRY * SYSTEM NOTICE REGISTRY
* *
* Rules for authoring: * Rules for authoring:
* - NEVER remove or renumber entries dismissal tracking is keyed by `id`. * - NEVER reuse a retired `id` dismissal tracking is keyed by `id`. Retired ids are
* listed in RETIRED_NOTICE_IDS so they're never accidentally re-used.
* - `id` must be globally unique and stable across deployments. * - `id` must be globally unique and stable across deployments.
* - Title: 40 chars, sentence case, no trailing punctuation. * - Title: 40 chars, sentence case, no trailing punctuation.
* - Body: markdown (modal) or plain text (banner/toast). 400/140/80 chars. * - Body: markdown (modal) or plain text (banner/toast). 400/140/80 chars.
* - CTA label: 20 chars, a verb. * - CTA label: 20 chars.
* - Never hardcode version numbers/dates in translated strings use bodyParams. * - Never hardcode version numbers/dates in translated strings use bodyParams.
* - See plans/system-notices/00-overview.md for full authoring guidelines.
*/ */
/**
* Retired notices. Kept out of the active list but their ids stay reserved so a future
* notice never reuses one (dismissals are keyed by id). Do not re-add these ids.
*/
export const RETIRED_NOTICE_IDS = [
'v3-thankyou',
'v3-photos',
'v3-journey',
'v3-mcp',
'v3-features',
'welcome-v1',
] as const;
export const SYSTEM_NOTICES: SystemNotice[] = [ export const SYSTEM_NOTICES: SystemNotice[] = [
// ── 3.0.0 upgrade notices (shown as a multipage modal to pre-3.0 users) ───── // ── Thank-you + support the project — shown once per install AND once per upgrade ──
// `recurring: 'per-version'` re-surfaces it whenever the app version moves up.
{ {
// Page 1 — breaking change first (warn → sorts before the two info notices) id: 'thank-you-support',
id: 'v3-photos',
display: 'modal',
severity: 'warn',
icon: 'ImageOff',
titleKey: 'system_notice.v3_photos.title',
bodyKey: 'system_notice.v3_photos.body',
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 90,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 2 — flagship feature (only when Journey addon is enabled)
id: 'v3-journey',
display: 'modal',
severity: 'info',
icon: 'BookOpen',
titleKey: 'system_notice.v3_journey.title',
bodyKey: 'system_notice.v3_journey.body',
highlights: [
{ labelKey: 'system_notice.v3_journey.highlight_timeline', iconName: 'CalendarDays' },
{ labelKey: 'system_notice.v3_journey.highlight_photos', iconName: 'Images' },
{ labelKey: 'system_notice.v3_journey.highlight_share', iconName: 'Globe' },
{ labelKey: 'system_notice.v3_journey.highlight_export', iconName: 'FileText' },
],
cta: {
kind: 'nav',
labelKey: 'system_notice.v3_journey.cta_label',
href: '/journey',
},
dismissible: true,
conditions: [
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
{ kind: 'addonEnabled', addonId: 'journey' },
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 80,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 3 — MCP OAuth 2.1 upgrade (only when MCP addon is enabled)
id: 'v3-mcp',
display: 'modal',
severity: 'warn',
icon: 'Bot',
titleKey: 'system_notice.v3_mcp.title',
bodyKey: 'system_notice.v3_mcp.body',
highlights: [
{ labelKey: 'system_notice.v3_mcp.highlight_oauth', iconName: 'KeyRound' },
{ labelKey: 'system_notice.v3_mcp.highlight_scopes', iconName: 'ShieldCheck' },
{ labelKey: 'system_notice.v3_mcp.highlight_deprecated', iconName: 'AlertTriangle' },
{ labelKey: 'system_notice.v3_mcp.highlight_tools', iconName: 'Wrench' },
],
dismissible: true,
conditions: [
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
{ kind: 'addonEnabled', addonId: 'mcp' },
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 75,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 4 — other highlights
id: 'v3-features',
display: 'modal',
severity: 'info',
icon: 'Sparkles',
titleKey: 'system_notice.v3_features.title',
bodyKey: 'system_notice.v3_features.body',
highlights: [
{ labelKey: 'system_notice.v3_features.highlight_dashboard', iconName: 'LayoutDashboard' },
{ labelKey: 'system_notice.v3_features.highlight_offline', iconName: 'WifiOff' },
{ labelKey: 'system_notice.v3_features.highlight_search', iconName: 'Search' },
{ labelKey: 'system_notice.v3_features.highlight_import', iconName: 'FileInput' },
],
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 70,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 1 — personal thank-you from the creator (shown first)
id: 'v3-thankyou',
display: 'modal', display: 'modal',
severity: 'info', severity: 'info',
icon: 'Heart', icon: 'Heart',
titleKey: 'system_notice.v3_thankyou.title', titleKey: 'system_notice.thank_you_support.title',
bodyKey: 'system_notice.v3_thankyou.body', bodyKey: 'system_notice.thank_you_support.body',
highlights: [
{ labelKey: 'system_notice.thank_you_support.highlight_opensource', iconName: 'Github' },
{ labelKey: 'system_notice.thank_you_support.highlight_free', iconName: 'Infinity' },
{ labelKey: 'system_notice.thank_you_support.highlight_community', iconName: 'Users' },
],
cta: {
kind: 'link',
labelKey: 'system_notice.thank_you_support.cta_bmc',
href: 'https://buymeacoffee.com/mauriceboe',
},
secondaryCta: {
kind: 'link',
labelKey: 'system_notice.thank_you_support.cta_kofi',
href: 'https://ko-fi.com/mauriceboe',
},
dismissible: true, dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], // Desktop-only: the support modal is suppressed on small/mobile viewports.
publishedAt: '2026-04-16T00:00:00Z', desktopOnly: true,
priority: 95, conditions: [],
minVersion: '3.0.0', publishedAt: '2026-06-27T00:00:00Z',
maxVersion: '4.0.0', priority: 100,
recurring: 'per-version',
}, },
// ── 3.0.14 admin notice — whitespace migration collision ─────────────────── // ── 3.0.14 admin notice — whitespace migration collision ───────────────────
// Operational alert (not promo): shown only to admins who upgraded across the
// 3.0.14 boundary AND only when the migration actually renamed colliding accounts.
{ {
id: 'v3014-whitespace-collision', id: 'v3014-whitespace-collision',
display: 'banner', display: 'banner',
@@ -150,29 +87,4 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
priority: 85, priority: 85,
minVersion: '3.0.14', minVersion: '3.0.14',
}, },
// ── Onboarding ─────────────────────────────────────────────────────────────
{
id: 'welcome-v1',
display: 'modal',
severity: 'info',
icon: 'Sparkles',
titleKey: 'system_notice.welcome_v1.title',
bodyKey: 'system_notice.welcome_v1.body',
highlights: [
{ labelKey: 'system_notice.welcome_v1.highlight_plan', iconName: 'Map' },
{ labelKey: 'system_notice.welcome_v1.highlight_share', iconName: 'Users' },
{ labelKey: 'system_notice.welcome_v1.highlight_offline', iconName: 'WifiOff' },
],
cta: {
kind: 'action',
labelKey: 'system_notice.welcome_v1.cta_label',
actionId: 'open:trip-create',
},
dismissible: true,
conditions: [{ kind: 'firstLogin' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 100,
},
]; ];
+27 -9
View File
@@ -46,19 +46,32 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?' 'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?'
).get(userId) as { count: number }; ).get(userId) as { count: number };
const dismissedIds = new Set<string>( // Dismissals mapped to the app version they were dismissed at (used by per-version notices).
(db.prepare('SELECT notice_id FROM user_notice_dismissals WHERE user_id = ?') const dismissals = new Map<string, string | null>(
.all(userId) as Array<{ notice_id: string }>) (db.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?')
.map(r => r.notice_id) .all(userId) as Array<{ notice_id: string; dismissed_app_version: string | null }>)
.map(r => [r.notice_id, r.dismissed_app_version])
); );
const now = new Date(); const now = new Date();
const currentAppVersion = getCurrentAppVersion(); const currentAppVersion = getCurrentAppVersion();
const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now }; const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now };
const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
const isStillDismissed = (n: SystemNotice): boolean => {
if (!dismissals.has(n.id)) return false;
if (n.recurring === 'per-version') {
// Re-show once the running app version moves past the version it was last dismissed at,
// so a per-version notice surfaces again on each install/upgrade.
const dismissedVer = semver.coerce(dismissals.get(n.id) ?? '0.0.0')?.version ?? '0.0.0';
return semver.gte(dismissedVer, appVer);
}
return true; // default: permanent one-time dismissal
};
return SYSTEM_NOTICES return SYSTEM_NOTICES
.filter(n => { .filter(n => {
if (dismissedIds.has(n.id)) return false; if (isStillDismissed(n)) return false;
if (!isNoticeVersionActive(n, currentAppVersion)) return false; if (!isNoticeVersionActive(n, currentAppVersion)) return false;
return evaluate(n, ctx); return evaluate(n, ctx);
}) })
@@ -69,15 +82,20 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
if (sw !== 0) return sw; if (sw !== 0) return sw;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(); return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
}) })
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto); .map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, recurring: _rc, ...dto }) => dto);
} }
export function dismissNotice(userId: number, noticeId: string): boolean { export function dismissNotice(userId: number, noticeId: string): boolean {
const exists = SYSTEM_NOTICES.some(n => n.id === noticeId); const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
if (!exists) return false; if (!exists) return false;
// Record the app version at dismissal so per-version notices can re-appear on the next
// upgrade. Upsert (not INSERT OR IGNORE) so re-dismissing after a bump refreshes the version.
db.prepare(` db.prepare(`
INSERT OR IGNORE INTO user_notice_dismissals (user_id, notice_id, dismissed_at) INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
`).run(userId, noticeId, Date.now()); ON CONFLICT(user_id, notice_id) DO UPDATE SET
dismissed_at = excluded.dismissed_at,
dismissed_app_version = excluded.dismissed_app_version
`).run(userId, noticeId, Date.now(), getCurrentAppVersion());
return true; return true;
} }
+8 -1
View File
@@ -21,6 +21,7 @@ export interface NoticeMedia {
export type NoticeCta = export type NoticeCta =
| { kind: 'nav'; labelKey: string; href: string } | { kind: 'nav'; labelKey: string; href: string }
| { kind: 'link'; labelKey: string; href: string } // external URL, opens in a new tab
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; | { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
export interface SystemNotice { export interface SystemNotice {
@@ -34,13 +35,19 @@ export interface SystemNotice {
media?: NoticeMedia; media?: NoticeMedia;
highlights?: Array<{ labelKey: string; iconName?: string }>; highlights?: Array<{ labelKey: string; iconName?: string }>;
cta?: NoticeCta; cta?: NoticeCta;
secondaryCta?: NoticeCta;
// Hide this notice on small/mobile viewports (evaluated client-side).
desktopOnly?: boolean;
dismissible: boolean; dismissible: boolean;
conditions: NoticeCondition[]; conditions: NoticeCondition[];
publishedAt: string; publishedAt: string;
minVersion?: string; minVersion?: string;
maxVersion?: string; maxVersion?: string;
priority?: number; priority?: number;
// 'per-version': re-show on every app version bump (each install + upgrade) instead of
// the default permanent one-time dismissal.
recurring?: 'per-version';
} }
// DTO sent to client (same shape minus the conditions — server evaluates those) // DTO sent to client (same shape minus the conditions — server evaluates those)
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>; export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority' | 'recurring'>;
+2
View File
@@ -67,6 +67,7 @@ export interface Place {
notes?: string | null; notes?: string | null;
image_url?: string | null; image_url?: string | null;
google_place_id?: string | null; google_place_id?: string | null;
google_ftid?: string | null;
osm_id?: string | null; osm_id?: string | null;
website?: string | null; website?: string | null;
phone?: string | null; phone?: string | null;
@@ -323,6 +324,7 @@ export interface AssignmentRow extends DayAssignment {
image_url: string | null; image_url: string | null;
transport_mode: string; transport_mode: string;
google_place_id: string | null; google_place_id: string | null;
google_ftid: string | null;
website: string | null; website: string | null;
phone: string | null; phone: string | null;
category_name: string | null; category_name: string | null;
+112 -1
View File
@@ -54,7 +54,7 @@ import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema'; import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations'; import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories'; import { createUser, createTrip, createReservation, createPlace, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth'; import { authCookie, generateToken } from '../helpers/auth';
let nestApp: INestApplication; let nestApp: INestApplication;
@@ -357,6 +357,117 @@ describe('File links', () => {
}); });
}); });
// ─────────────────────────────────────────────────────────────────────────────
// Cross-trip link isolation (GHSA — reservation title disclosure)
//
// A file may only point at reservations / assignments / places from its own
// trip. The reservation JOIN returns the reservation title, so a member of one
// trip linking a file to another private trip's reservation id used to read the
// foreign title back. Every write path (link, upload, update) must reject it.
// ─────────────────────────────────────────────────────────────────────────────
describe('Cross-trip link isolation', () => {
it('SEC-FILE-LINK-001 — linking a file to a reservation from another trip is rejected (no title leak)', async () => {
const { user: attacker } = createUser(testDb);
const { user: victim } = createUser(testDb);
const attackerTrip = createTrip(testDb, attacker.id, { title: 'Attacker Trip' });
const victimTrip = createTrip(testDb, victim.id, { title: 'Victim Trip' });
const victimReservation = createReservation(testDb, victimTrip.id, { title: 'Victim Secret Flight', type: 'flight' });
const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const link = await request(app)
.post(`/api/trips/${attackerTrip.id}/files/${fileId}/link`)
.set('Cookie', authCookie(attacker.id))
.send({ reservation_id: victimReservation.id });
expect(link.status).toBe(400);
// Nothing was stored, so the title cannot leak back through the links list.
const links = await request(app)
.get(`/api/trips/${attackerTrip.id}/files/${fileId}/links`)
.set('Cookie', authCookie(attacker.id));
expect(links.status).toBe(200);
expect(JSON.stringify(links.body)).not.toContain('Victim Secret Flight');
expect((links.body.links as any[]).some((l) => l.reservation_id === victimReservation.id)).toBe(false);
});
it('SEC-FILE-LINK-002 — uploading a file with a foreign reservation_id is rejected (no title leak)', async () => {
const { user: attacker } = createUser(testDb);
const { user: victim } = createUser(testDb);
const attackerTrip = createTrip(testDb, attacker.id);
const victimTrip = createTrip(testDb, victim.id);
const victimReservation = createReservation(testDb, victimTrip.id, { title: 'Victim Secret Flight', type: 'flight' });
const res = await request(app)
.post(`/api/trips/${attackerTrip.id}/files`)
.set('Cookie', authCookie(attacker.id))
.field('reservation_id', String(victimReservation.id))
.attach('file', FIXTURE_PDF);
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).not.toContain('Victim Secret Flight');
});
it('SEC-FILE-LINK-003 — updating a file with a foreign reservation_id is rejected (no title leak)', async () => {
const { user: attacker } = createUser(testDb);
const { user: victim } = createUser(testDb);
const attackerTrip = createTrip(testDb, attacker.id);
const victimTrip = createTrip(testDb, victim.id);
const victimReservation = createReservation(testDb, victimTrip.id, { title: 'Victim Secret Flight', type: 'flight' });
const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const res = await request(app)
.put(`/api/trips/${attackerTrip.id}/files/${fileId}`)
.set('Cookie', authCookie(attacker.id))
.send({ reservation_id: victimReservation.id });
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).not.toContain('Victim Secret Flight');
});
it('SEC-FILE-LINK-004 — linking a file to a place from another trip is rejected', async () => {
const { user: attacker } = createUser(testDb);
const { user: victim } = createUser(testDb);
const attackerTrip = createTrip(testDb, attacker.id);
const victimTrip = createTrip(testDb, victim.id);
const victimPlace = createPlace(testDb, victimTrip.id, { name: 'Victim Secret Place' });
const upload = await uploadFile(attackerTrip.id, attacker.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const link = await request(app)
.post(`/api/trips/${attackerTrip.id}/files/${fileId}/link`)
.set('Cookie', authCookie(attacker.id))
.send({ place_id: victimPlace.id });
expect(link.status).toBe(400);
const links = await request(app)
.get(`/api/trips/${attackerTrip.id}/files/${fileId}/links`)
.set('Cookie', authCookie(attacker.id));
expect((links.body.links as any[]).some((l) => l.place_id === victimPlace.id)).toBe(false);
});
it('SEC-FILE-LINK-005 — same-trip reservation links and uploads still succeed', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const resv = createReservation(testDb, trip.id, { title: 'My Own Flight', type: 'flight' });
// Upload carrying the trip's own reservation id is accepted.
const upload = await request(app)
.post(`/api/trips/${trip.id}/files`)
.set('Cookie', authCookie(user.id))
.field('reservation_id', String(resv.id))
.attach('file', FIXTURE_PDF);
expect(upload.status).toBe(201);
const fileId = upload.body.file.id;
// And linking it to the same reservation works.
const link = await request(app)
.post(`/api/trips/${trip.id}/files/${fileId}/link`)
.set('Cookie', authCookie(user.id))
.send({ reservation_id: resv.id });
expect(link.status).toBe(200);
});
});
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Download // Download
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
+37 -7
View File
@@ -92,16 +92,17 @@ describe('GET /api/system-notices/active', () => {
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('returns empty array for non-first-login user with no applicable notices', async () => { it('returns no login/version-gated notices for an established user', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
// login_count > 1 means firstLogin condition does not match for any notice; // login_count > 1 means firstLogin does not match; first_seen_version >= 3.0.0 means
// first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match // existingUserBeforeVersion('3.0.0') does not match either. The always-on thank-you
// notice (no conditions) may still apply, so only filter it out.
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app) const res = await request(app)
.get('/api/system-notices/active') .get('/api/system-notices/active')
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual([]); expect(res.body.filter((n: { id: string }) => n.id !== 'thank-you-support')).toEqual([]);
}); });
it('returns firstLogin notice for user with login_count <= 1', async () => { it('returns firstLogin notice for user with login_count <= 1', async () => {
@@ -115,7 +116,7 @@ describe('GET /api/system-notices/active', () => {
.get('/api/system-notices/active') .get('/api/system-notices/active')
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
expect(res.status).toBe(200); expect(res.status).toBe(200);
// welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present // The always-on thank-you notice may also be present, so just assert TEST_NOTICE is there
const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id); const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
expect(testNotice).toBeDefined(); expect(testNotice).toBeDefined();
// DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority // DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority
@@ -139,7 +140,7 @@ describe('GET /api/system-notices/active', () => {
.get('/api/system-notices/active') .get('/api/system-notices/active')
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual([]); expect(res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined();
} finally { } finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
@@ -161,7 +162,7 @@ describe('GET /api/system-notices/active', () => {
.get('/api/system-notices/active') .get('/api/system-notices/active')
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
expect(res.status).toBe(200); expect(res.status).toBe(200);
// TEST_NOTICE should be filtered out; welcome-v1 may still appear // TEST_NOTICE should be filtered out; the thank-you notice may still appear
const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id); const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
expect(found).toBeUndefined(); expect(found).toBeUndefined();
} finally { } finally {
@@ -169,6 +170,35 @@ describe('GET /api/system-notices/active', () => {
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
} }
}); });
it('re-surfaces a per-version notice after an upgrade but hides it within the same version', async () => {
const TY = 'thank-you-support';
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const shows = async () => {
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
return res.body.some((n: { id: string }) => n.id === TY);
};
// Fresh user with no dismissal: the recurring thank-you shows.
expect(await shows()).toBe(true);
// Dismissed at an old version → it returns once the running version is newer.
testDb.prepare(
'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?)'
).run(user.id, TY, Date.now(), '0.0.1');
expect(await shows()).toBe(true);
// Dismissed at a version >= the running one → stays hidden until the next upgrade.
testDb.prepare(
'UPDATE user_notice_dismissals SET dismissed_app_version = ? WHERE user_id = ? AND notice_id = ?'
).run('99.0.0', user.id, TY);
expect(await shows()).toBe(false);
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -28,6 +28,17 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
throw new Error('expected the handler to throw'); throw new Error('expected the handler to throw');
} }
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected the handler to throw');
}
describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => { describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => {
it('404 when the trip is not accessible', () => { it('404 when the trip is not accessible', () => {
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }); const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
@@ -145,51 +156,51 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
}); });
describe('POST /', () => { describe('POST /', () => {
it('403 without budget_edit', () => { it('403 without budget_edit', async () => {
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) }); const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
expect(thrown(() => new BudgetController(svc).create(user, '5', { name: 'Hotel' }))).toEqual({ expect(await thrownAsync(() => new BudgetController(svc).create(user, '5', { name: 'Hotel' }))).toEqual({
status: 403, body: { error: 'No permission' }, status: 403, body: { error: 'No permission' },
}); });
}); });
it('400 when name missing', () => { it('400 when name missing', async () => {
expect(thrown(() => new BudgetController(makeService()).create(user, '5', {}))).toEqual({ expect(await thrownAsync(() => new BudgetController(makeService()).create(user, '5', {}))).toEqual({
status: 400, body: { error: 'Name is required' }, status: 400, body: { error: 'Name is required' },
}); });
}); });
it('creates and broadcasts', () => { it('creates and broadcasts', async () => {
const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' }); const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' });
const broadcast = vi.fn(); const broadcast = vi.fn();
const svc = makeService({ create, broadcast } as Partial<BudgetService>); const svc = makeService({ create, broadcast } as Partial<BudgetService>);
expect(new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } }); expect(await new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock');
}); });
}); });
describe('PUT /:id', () => { describe('PUT /:id', () => {
it('404 when item missing', () => { it('404 when item missing', async () => {
const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial<BudgetService>); const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
expect(thrown(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
status: 404, body: { error: 'Budget item not found' }, status: 404, body: { error: 'Budget item not found' },
}); });
}); });
it('syncs the reservation price when a linked item changes total_price', () => { it('syncs the reservation price when a linked item changes total_price', async () => {
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 }); const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 });
const syncReservationPrice = vi.fn(); const syncReservationPrice = vi.fn();
const broadcast = vi.fn(); const broadcast = vi.fn();
const svc = makeService({ update, syncReservationPrice, broadcast } as Partial<BudgetService>); const svc = makeService({ update, syncReservationPrice, broadcast } as Partial<BudgetService>);
new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock'); await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock');
expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock'); expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock');
}); });
it('does not sync when the item has no linked reservation', () => { it('does not sync when the item has no linked reservation', async () => {
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 }); const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 });
const syncReservationPrice = vi.fn(); const syncReservationPrice = vi.fn();
const svc = makeService({ update, syncReservationPrice } as Partial<BudgetService>); const svc = makeService({ update, syncReservationPrice } as Partial<BudgetService>);
new BudgetController(svc).update(user, '5', '9', { total_price: 250 }); await new BudgetController(svc).update(user, '5', '9', { total_price: 250 });
expect(syncReservationPrice).not.toHaveBeenCalled(); expect(syncReservationPrice).not.toHaveBeenCalled();
}); });
}); });
@@ -103,10 +103,10 @@ describe('BudgetService', () => {
}); });
}); });
it('create / update / remove / members / paid / payers delegate', () => { it('create / update / remove / members / paid / payers delegate', async () => {
svc().create('5', { name: 'Hotel' } as never); await svc().create('5', { name: 'Hotel' } as never);
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' }); expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
svc().update('9', '5', { name: 'X' }); await svc().update('9', '5', { name: 'X' });
expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' }); expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' });
svc().remove('9', '5'); svc().remove('9', '5');
expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5'); expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5');
@@ -21,6 +21,7 @@ function fsvc(o: Partial<FilesService> = {}): FilesService {
return { return {
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }), verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
can: vi.fn().mockReturnValue(true), can: vi.fn().mockReturnValue(true),
findForeignLinkTarget: vi.fn().mockReturnValue(null),
broadcast: vi.fn(), broadcast: vi.fn(),
...o, ...o,
} as unknown as FilesService; } as unknown as FilesService;
@@ -47,7 +47,7 @@ describe('airtrailMapper.normalizeFlight', () => {
fromCode: 'JFK', fromCode: 'JFK',
toCode: 'LHR', toCode: 'LHR',
date: '2021-09-01', date: '2021-09-01',
airline: 'BAW', airline: 'British Airways',
flightNumber: 'BA178', flightNumber: 'BA178',
seatClass: 'economy', seatClass: 'economy',
}); });
@@ -98,12 +98,19 @@ describe('airtrailMapper.mapFlightToReservation', () => {
it('carries flight metadata', () => { it('carries flight metadata', () => {
const m = mapFlightToReservation(flight()); const m = mapFlightToReservation(flight());
expect(m.metadata).toMatchObject({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }); // #1334: display the airline name, keep the code in airline_code for the writeback.
expect(m.metadata).toMatchObject({ airline: 'British Airways', airline_code: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' });
expect(m.type).toBe('flight'); expect(m.type).toBe('flight');
expect(m.status).toBe('confirmed'); expect(m.status).toBe('confirmed');
expect(m.notes).toBe('window seat'); expect(m.notes).toBe('window seat');
}); });
it('#1334 falls back to the airline code when AirTrail provides no name', () => {
const a = { id: 9, icao: 'EWG', iata: 'EW' };
expect(normalizeFlight(flight({ airline: a })).airline).toBe('EWG');
expect(mapFlightToReservation(flight({ airline: a })).metadata).toMatchObject({ airline: 'EWG', airline_code: 'EWG' });
});
it('uses only the seat number for the seat, not the cabin class (#1246)', () => { it('uses only the seat number for the seat, not the cabin class (#1246)', () => {
// AirTrail often has a class but no seat number until check-in; the class // AirTrail often has a class but no seat number until check-in; the class
// must not leak into the seat field. // must not leak into the seat field.
@@ -171,6 +171,23 @@ describe('getCountryFromCoords', () => {
const code = getCountryFromCoords(0.0, 0.0); const code = getCountryFromCoords(0.0, 0.0);
expect(code).toBeNull(); expect(code).toBeNull();
}); });
it('ATLAS-SVC-005b: #1331 a point inside France near the German border resolves to FR, not the smaller overlapping box', () => {
// Strasbourg (48.573, 7.752) sits inside BOTH the FR and DE bounding boxes; the old
// smallest-box rule mis-picked DE (its box is smaller). Point-in-polygon picks FR.
expect(getCountryFromCoords(48.5734, 7.7521)).toBe('FR');
});
it('ATLAS-SVC-005c: #1331 a point inside Germany near the French border resolves to DE', () => {
// Kehl (48.575, 7.815) — the German side of the same border.
expect(getCountryFromCoords(48.5750, 7.8150)).toBe('DE');
});
it('ATLAS-SVC-005d: #1331 a micro-territory without an admin0 polygon keeps the smallest-box win (Hong Kong)', () => {
// HK is not a separate admin0 polygon (it falls inside CN there), so the smallest
// bounding box still wins for it.
expect(getCountryFromCoords(22.30, 114.17)).toBe('HK');
});
}); });
// ── getCountryFromAddress ─────────────────────────────────────────────────── // ── getCountryFromAddress ───────────────────────────────────────────────────
@@ -209,6 +209,33 @@ describe('calculateSettlement', () => {
expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: 1 }), to: expect.objectContaining({ user_id: 2 }) }), expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: 1 }), to: expect.objectContaining({ user_id: 2 }) }),
]); ]);
}); });
it('#1335 converts a foreign expense with the frozen exchange_rate, not live rates', () => {
// $110 booked at a frozen rate of 1.1 (USD per 1 EUR) = 100 EUR. Live rates have since
// drifted to 1.2, but the converted amount must stay on the frozen rate so an already
// settled position isn't re-opened with a residual.
setupDb(
[{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 110, 'alice')],
);
const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
const bob = result.balances.find(b => b.user_id === 2)!;
// 110 / 1.1 = 100 EUR; Bob owes half = 50 (frozen). With the live 1.2 it would be ~45.83.
expect(bob.balance).toBeCloseTo(-50, 2);
});
it('#1335 a legacy row (exchange_rate = 1) still converts with live rates', () => {
setupDb(
[{ ...makeItem(1, 120), currency: 'USD', exchange_rate: 1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
[makePayer(1, 1, 120, 'alice')],
);
const result = calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.2 } });
const bob = result.balances.find(b => b.user_id === 2)!;
// 120 / 1.2 (live) = 100 EUR; Bob owes 50 — unchanged behaviour for pre-#1335 rows.
expect(bob.balance).toBeCloseTo(-50, 2);
});
}); });
// ── updateSettlement ────────────────────────────────────────────────────────── // ── updateSettlement ──────────────────────────────────────────────────────────
+128 -2
View File
@@ -73,6 +73,11 @@ import {
parseOpeningHours, parseOpeningHours,
buildOsmDetails, buildOsmDetails,
getMapsKey, getMapsKey,
googleFtidFromMapsUrl,
buildUserAgent,
resolveOverpassEndpoints,
resolveOverpassTimeoutMs,
searchOverpassPois,
} from '../../../src/services/mapsService'; } from '../../../src/services/mapsService';
afterEach(() => { afterEach(() => {
@@ -751,13 +756,21 @@ describe('searchPlaces (fetch stubbed)', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ 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 { searchPlaces } = await import('../../../src/services/mapsService');
const result = await searchPlaces(1, 'Eiffel Tower'); const result = await searchPlaces(1, 'Eiffel Tower');
expect(result.source).toBe('google'); expect(result.source).toBe('google');
expect((result.places[0] as any).google_place_id).toBe('gid1'); 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 () => { it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
@@ -813,6 +826,7 @@ describe('searchPlaces (fetch stubbed)', () => {
const result = await searchPlaces(1, 'sparse'); const result = await searchPlaces(1, 'sparse');
const place = result.places[0] as any; const place = result.places[0] as any;
expect(place.google_place_id).toBe('gid-sparse'); expect(place.google_place_id).toBe('gid-sparse');
expect(place.google_ftid).toBeNull();
expect(place.name).toBe(''); expect(place.name).toBe('');
expect(place.address).toBe(''); expect(place.address).toBe('');
expect(place.lat).toBeNull(); expect(place.lat).toBeNull();
@@ -1082,7 +1096,9 @@ describe('getPlaceDetails (fetch stubbed)', () => {
weekdayDescriptions: ['Monday: 9:00 AM 12:00 AM'], weekdayDescriptions: ['Monday: 9:00 AM 12:00 AM'],
openNow: true, 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.' }, editorialSummary: { text: 'Iconic iron tower.' },
reviews: [ reviews: [
{ {
@@ -1099,6 +1115,7 @@ describe('getPlaceDetails (fetch stubbed)', () => {
const result = await getPlaceDetails(1, 'ChIJ123'); const result = await getPlaceDetails(1, 'ChIJ123');
const place = result.place as any; const place = result.place as any;
expect(place.google_place_id).toBe('ChIJ123'); expect(place.google_place_id).toBe('ChIJ123');
expect(place.google_ftid).toBeNull();
expect(place.name).toBe('Eiffel Tower'); expect(place.name).toBe('Eiffel Tower');
expect(place.rating).toBe(4.7); expect(place.rating).toBe(4.7);
expect(place.rating_count).toBe(200000); expect(place.rating_count).toBe(200000);
@@ -1467,3 +1484,112 @@ describe('getPlacePhoto (fetch stubbed)', () => {
expect(mockCachePut).toHaveBeenCalledOnce(); 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();
});
});
// ── buildUserAgent (instance-specific UA, #1309) ──────────────────────────────
describe('buildUserAgent', () => {
const base = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
it('MAPS-094: returns the bare base UA when no instance URL is configured', () => {
expect(buildUserAgent(undefined)).toBe(base);
expect(buildUserAgent('')).toBe(base);
});
it('MAPS-095: appends a configured https instance URL so the deployment is identifiable', () => {
expect(buildUserAgent('https://trek.example.org')).toBe(`${base}; https://trek.example.org`);
});
it('MAPS-096: drops the http://localhost fallback — it is not a unique identifier', () => {
expect(buildUserAgent('http://localhost:3001')).toBe(base);
});
});
// ── resolveOverpassEndpoints (OVERPASS_URL override, #1309) ────────────────────
describe('resolveOverpassEndpoints', () => {
it('MAPS-097: falls back to the public mirrors when OVERPASS_URL is unset/empty', () => {
expect(resolveOverpassEndpoints(undefined).length).toBeGreaterThan(1);
expect(resolveOverpassEndpoints('').length).toBeGreaterThan(1);
expect(resolveOverpassEndpoints(undefined)[0]).toContain('overpass-api.de');
});
it('MAPS-098: a single custom endpoint REPLACES the public mirrors (locked-down egress)', () => {
expect(resolveOverpassEndpoints('https://overpass.internal/api/interpreter'))
.toEqual(['https://overpass.internal/api/interpreter']);
});
it('MAPS-099: parses a comma-separated list and trims whitespace', () => {
expect(resolveOverpassEndpoints(' https://a.test/api , http://b.test/api '))
.toEqual(['https://a.test/api', 'http://b.test/api']);
});
it('MAPS-100: drops non-http(s) / malformed entries, keeping the valid ones', () => {
expect(resolveOverpassEndpoints('https://ok.test/api, ftp://no.test, not a url'))
.toEqual(['https://ok.test/api']);
});
it('MAPS-101: falls back to the defaults when every custom entry is invalid', () => {
expect(resolveOverpassEndpoints('not a url, ftp://no.test').length).toBeGreaterThan(1);
});
});
// ── resolveOverpassTimeoutMs (OVERPASS_TIMEOUT_MS override, #1309) ─────────────
describe('resolveOverpassTimeoutMs', () => {
it('MAPS-104: falls back to the 12s default for unset / empty / non-numeric values', () => {
expect(resolveOverpassTimeoutMs(undefined)).toBe(12000);
expect(resolveOverpassTimeoutMs('')).toBe(12000);
expect(resolveOverpassTimeoutMs('abc')).toBe(12000);
});
it('MAPS-105: honours a positive numeric override', () => {
expect(resolveOverpassTimeoutMs('30000')).toBe(30000);
});
it('MAPS-106: rejects 0, negative and Infinity — a non-positive cap would 502 every search', () => {
expect(resolveOverpassTimeoutMs('0')).toBe(12000);
expect(resolveOverpassTimeoutMs('-5')).toBe(12000);
expect(resolveOverpassTimeoutMs('Infinity')).toBe(12000);
});
});
// ── searchOverpassPois error path (all endpoints down, #1309) ──────────────────
describe('searchOverpassPois all-endpoints-down', () => {
const bbox = { south: -41.2, west: 146.31, north: -41.16, east: 146.37 };
it('MAPS-102: surfaces a 502 with a clear message when every Overpass endpoint fails', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
await expect(searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({
status: 502,
message: 'Could not reach any Overpass endpoint',
});
errSpy.mockRestore();
});
it('MAPS-103: logs each endpoint failure so an operator can diagnose blocked egress', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
await expect(searchOverpassPois('bar', bbox)).rejects.toThrow();
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all'));
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED'));
errSpy.mockRestore();
});
});
@@ -416,6 +416,15 @@ describe('importGoogleList', () => {
expect(result.status).toBe(400); expect(result.status).toBe(400);
}); });
it('PLACE-SVC-026b — a single-place link gives a guiding error instead of the generic one (#1304)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const url = 'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,17z/data=!3m1';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.status).toBe(400);
expect(result.error).toMatch(/single place/i);
});
it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => { it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
@@ -449,6 +458,57 @@ describe('importGoogleList', () => {
expect(result.places[1].name).toBe('London'); 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 () => { it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
@@ -33,6 +33,7 @@ function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
image_url: 'https://example.com/img.jpg', image_url: 'https://example.com/img.jpg',
transport_mode: 'walk', transport_mode: 'walk',
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0', google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
google_ftid: '0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0',
website: 'https://eiffel-tower.com', website: 'https://eiffel-tower.com',
phone: '+33 1 2345 6789', phone: '+33 1 2345 6789',
...overrides, ...overrides,
@@ -66,6 +67,7 @@ describe('formatAssignmentWithPlace', () => {
expect(place.image_url).toBe('https://example.com/img.jpg'); expect(place.image_url).toBe('https://example.com/img.jpg');
expect(place.transport_mode).toBe('walk'); expect(place.transport_mode).toBe('walk');
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0'); expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
expect(place.google_ftid).toBe('0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0');
expect(place.website).toBe('https://eiffel-tower.com'); expect(place.website).toBe('https://eiffel-tower.com');
expect(place.phone).toBe('+33 1 2345 6789'); expect(place.phone).toBe('+33 1 2345 6789');
}); });
+32 -1
View File
@@ -34,7 +34,7 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations'; import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db'; import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories'; import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories';
import { exportICS, generateDays, deleteOldCover } from '../../../src/services/tripService'; import { exportICS, generateDays, deleteOldCover, updateTrip } from '../../../src/services/tripService';
import fs from 'fs'; import fs from 'fs';
beforeAll(() => { beforeAll(() => {
@@ -476,3 +476,34 @@ describe('deleteOldCover', () => {
} }
}); });
}); });
describe('resyncReservationDays (#1288)', () => {
const dayFor = (tripId: number, date: string) =>
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
const insertDatedReservation = (tripId: number, dayId: number, time: string) =>
Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')",
).run(tripId, dayId, time).lastInsertRowid);
it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00');
// Shift the whole range one day forward (days become 2025-06-02..06).
updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
// The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row.
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
});
it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const origDayId = dayFor(trip.id, '2025-06-02');
const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00');
// Shift far forward so 2025-06-02 is no longer covered by any day.
updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
expect(res.day_id).toBe(origDayId);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/shared", "name": "@trek/shared",
"version": "3.1.2", "version": "3.1.3",
"private": true, "private": true,
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.", "description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
"type": "module", "type": "module",
+1
View File
@@ -335,6 +335,7 @@ const admin: TranslationStrings = {
'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.', 'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)', 'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)',
'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)', 'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)',
'admin.defaultSettings.providerMapLibre': 'MapLibre (OpenFreeMap)',
'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك', 'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك',
'admin.defaultSettings.mapboxTokenHint': 'admin.defaultSettings.mapboxTokenHint':
'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.', 'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',

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