mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-28 09:41:47 +00:00
Compare commits
24 Commits
dev
..
a63e16fb65
| Author | SHA1 | Date | |
|---|---|---|---|
| a63e16fb65 | |||
| dfe98a057c | |||
| d5850041a7 | |||
| ad6e1ddcc8 | |||
| 66f661e2a1 | |||
| 17b4f72be6 | |||
| 7aefeb4c53 | |||
| 63fb5a9c89 | |||
| 17245c5a8c | |||
| 6ab4989c38 | |||
| ea7f7fd9f3 | |||
| 00738c8dbc | |||
| 438f71bbc6 | |||
| c15c89ca61 | |||
| f98058a3af | |||
| 39a3ee7ce7 | |||
| e09849d5b4 | |||
| b3fc5411ca | |||
| f524909008 | |||
| 264cf7d384 | |||
| cb7ce7f229 | |||
| d40c5ce7a6 | |||
| 2d79254c33 | |||
| e6fcbc7789 |
@@ -32,7 +32,6 @@ server/tests/
|
|||||||
server/vitest.config.ts
|
server/vitest.config.ts
|
||||||
server/reset-admin.js
|
server/reset-admin.js
|
||||||
**/*.test.ts
|
**/*.test.ts
|
||||||
**/*.spec.ts
|
|
||||||
wiki/
|
wiki/
|
||||||
scripts/
|
scripts/
|
||||||
charts/
|
charts/
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ COPY server/tsconfig.json ./server/
|
|||||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
|
||||||
COPY server/reset-admin.js ./server/reset-admin.js
|
|
||||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
COPY --from=client-builder /app/client/dist ./server/public
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0"?>
|
|
||||||
<CommunityApplications>
|
|
||||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
|
||||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
|
||||||
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
|
||||||
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
|
||||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
|
||||||
<DonateText>Support TREK development</DonateText>
|
|
||||||
</CommunityApplications>
|
|
||||||
+1
-1
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
- PVCs require a default StorageClass or specify one as needed.
|
||||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.1.3
|
version: 3.1.0
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.1.3"
|
appVersion: "3.1.0"
|
||||||
|
|||||||
@@ -70,9 +70,3 @@ 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 }}
|
|
||||||
|
|||||||
@@ -5,16 +5,9 @@ metadata:
|
|||||||
name: {{ include "trek.fullname" . }}-data
|
name: {{ include "trek.fullname" . }}-data
|
||||||
labels:
|
labels:
|
||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
{{- with .Values.persistence.data.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
{{- with .Values.persistence.data.storageClassName }}
|
|
||||||
storageClassName: {{ . | quote }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.persistence.data.size }}
|
storage: {{ .Values.persistence.data.size }}
|
||||||
@@ -25,16 +18,9 @@ metadata:
|
|||||||
name: {{ include "trek.fullname" . }}-uploads
|
name: {{ include "trek.fullname" . }}-uploads
|
||||||
labels:
|
labels:
|
||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
{{- with .Values.persistence.uploads.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
{{- with .Values.persistence.uploads.storageClassName }}
|
|
||||||
storageClassName: {{ . | quote }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.persistence.uploads.size }}
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
|
|||||||
@@ -67,12 +67,6 @@ 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.
|
||||||
@@ -104,13 +98,8 @@ persistence:
|
|||||||
enabled: true
|
enabled: true
|
||||||
data:
|
data:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
|
||||||
storageClassName: ""
|
|
||||||
annotations: {}
|
|
||||||
uploads:
|
uploads:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
storageClassName: ""
|
|
||||||
annotations: {}
|
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|||||||
+1
-1
@@ -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.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "3.1.3",
|
"version": "3.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,7 +34,6 @@
|
|||||||
"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",
|
||||||
@@ -82,7 +81,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.1.0",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ 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,16 +7,7 @@ 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 { DistanceUnit, Place } from '../../types'
|
import type { 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' },
|
||||||
@@ -28,7 +19,6 @@ 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
|
||||||
@@ -37,22 +27,18 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
type MapProvider = 'leaflet' | GlMapProvider
|
const MAPBOX_STYLE_PRESETS = [
|
||||||
|
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
||||||
function normalizeProvider(value: unknown): MapProvider {
|
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
||||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
||||||
}
|
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
||||||
|
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
||||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
||||||
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,
|
||||||
@@ -112,11 +98,10 @@ 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(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
setMapboxStyle(data.mapbox_style || '')
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => setLoaded(true))
|
}).catch(() => setLoaded(true))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -137,10 +122,7 @@ 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' || key === 'maplibre_style') {
|
if (key === 'mapbox_style') setMapboxStyle('')
|
||||||
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'))
|
||||||
@@ -190,20 +172,6 @@ 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}>
|
||||||
@@ -244,22 +212,6 @@ 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" /></>}>
|
||||||
{([
|
{([
|
||||||
@@ -364,21 +316,19 @@ 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={mapProvider === opt.value}
|
active={(defaults.map_provider || 'leaflet') === opt.value}
|
||||||
onClick={() => saveMapProvider(opt.value)}
|
onClick={() => save({ map_provider: opt.value })}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</OptionButton>
|
</OptionButton>
|
||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
{mapProvider !== 'leaflet' && (
|
{defaults.map_provider === 'mapbox-gl' && (
|
||||||
<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')}
|
||||||
@@ -396,18 +346,17 @@ 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={styleKey} />
|
<ResetButton field="mapbox_style" />
|
||||||
</label>
|
</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={mapboxStyle}
|
value={mapboxStyle}
|
||||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
|
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
||||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||||
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ marginBottom: 8 }}
|
style={{ marginBottom: 8 }}
|
||||||
/>
|
/>
|
||||||
@@ -415,18 +364,12 @@ 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={() => {
|
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
||||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
placeholder="mapbox://styles/mapbox/standard"
|
||||||
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' },
|
||||||
@@ -448,8 +391,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
</OptionButton>
|
</OptionButton>
|
||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||||
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||||
expect(nums()[2].value).toBe('50')
|
expect(nums()[2].value).toBe('50')
|
||||||
@@ -125,30 +125,6 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
|
|
||||||
let posted: Record<string, unknown> | null = null
|
|
||||||
server.use(
|
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
|
||||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
|
||||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
|
||||||
posted = await request.json() as Record<string, unknown>
|
|
||||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const { default: userEvent } = await import('@testing-library/user-event')
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
|
||||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
|
|
||||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
|
|
||||||
|
|
||||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
|
||||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
|
||||||
await waitFor(() => expect(posted).toBeTruthy())
|
|
||||||
expect(posted!.total_price).toBe(39.99)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks an expense with no payer as Unfinished', async () => {
|
it('marks an expense with no payer as Unfinished', async () => {
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||||
server.use(
|
server.use(
|
||||||
@@ -159,39 +135,4 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
await screen.findByText('Hotel')
|
await screen.findByText('Hotel')
|
||||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
|
||||||
let posted: Record<string, unknown> | null = null
|
|
||||||
server.use(
|
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
|
||||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
|
||||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
|
||||||
posted = await request.json() as Record<string, unknown>
|
|
||||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const { default: userEvent } = await import('@testing-library/user-event')
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
|
||||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
|
|
||||||
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
|
|
||||||
|
|
||||||
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
|
|
||||||
// The participant toggles are buttons; the same names also appear as plain text in
|
|
||||||
// the Balances sidebar, so target the buttons specifically.
|
|
||||||
await user.click(screen.getByRole('button', { name: /alice/i }))
|
|
||||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
|
||||||
|
|
||||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
|
||||||
const submit = addBtns[addBtns.length - 1] // footer submit
|
|
||||||
expect(submit).not.toBeDisabled()
|
|
||||||
await user.click(submit)
|
|
||||||
|
|
||||||
await waitFor(() => expect(posted).toBeTruthy())
|
|
||||||
expect(posted!.total_price).toBe(120)
|
|
||||||
expect(posted!.member_ids).toEqual([])
|
|
||||||
expect(posted!.payers).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -528,16 +528,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||||
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||||
<Icon size={21} />
|
|
||||||
{isMobile && isUnfinished && (
|
|
||||||
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
||||||
{isUnfinished && !isMobile && (
|
{isUnfinished && (
|
||||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
||||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
||||||
{t('costs.unfinished')}
|
{t('costs.unfinished')}
|
||||||
@@ -637,16 +632,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
|
|
||||||
function CategoryBreakdown() {
|
function CategoryBreakdown() {
|
||||||
const tot: Record<string, number> = {}
|
const tot: Record<string, number> = {}
|
||||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
|
let grand = 0
|
||||||
|
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
||||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||||
// Bars are scaled relative to the most expensive category (the top row fills the
|
|
||||||
// bar), not to the trip grand total — makes the relative ranking readable.
|
|
||||||
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{rows.map(c => {
|
{rows.map(c => {
|
||||||
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
|
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||||
@@ -761,8 +754,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('costs.amount')}</label>
|
<label className={labelCls}>{t('costs.amount')}</label>
|
||||||
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
|
||||||
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -818,10 +811,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
const paidEntered = paidSum > 0
|
const paidEntered = paidSum > 0
|
||||||
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
||||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||||
// No participants = a recorded total with nobody to split with (e.g. a booking
|
const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
|
||||||
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
|
|
||||||
// people only adds the who-owes-whom split on top.
|
|
||||||
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
|
|
||||||
|
|
||||||
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
||||||
const splitCents = (amount: number, n: number): number[] => {
|
const splitCents = (amount: number, n: number): number[] => {
|
||||||
@@ -843,12 +833,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onTotalChange = (v: string) => {
|
const onTotalChange = (v: string) => {
|
||||||
v = v.replace(',', '.')
|
|
||||||
setTotal(v)
|
setTotal(v)
|
||||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||||
}
|
}
|
||||||
const onPaidChange = (id: number, v: string) => {
|
const onPaidChange = (id: number, v: string) => {
|
||||||
v = v.replace(',', '.')
|
|
||||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||||
setDirty(nextDirty)
|
setDirty(nextDirty)
|
||||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||||
@@ -908,7 +896,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||||
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
|
||||||
onChange={e => onTotalChange(e.target.value)}
|
onChange={e => onTotalChange(e.target.value)}
|
||||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -968,7 +956,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
{on ? (
|
{on ? (
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||||
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
|
||||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
onChange={e => onPaidChange(p.id, e.target.value)}
|
||||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -981,7 +969,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||||
<span className="text-content-faint">
|
<span className="text-content-faint">
|
||||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||||
</span>
|
</span>
|
||||||
{paidEntered
|
{paidEntered
|
||||||
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
|
import { forwardRef, 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 type { JourneyMapGLHandle } from './JourneyMapGL'
|
import JourneyMapGL, { 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
|
||||||
@@ -41,9 +37,8 @@ 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. MapLibre/OpenFreeMap is tokenless.
|
// supplied a token yet — otherwise the map would just show a stub.
|
||||||
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
|
const useGL = 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),
|
||||||
@@ -52,12 +47,8 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
|
|||||||
}), [useGL])
|
}), [useGL])
|
||||||
|
|
||||||
if (useGL) {
|
if (useGL) {
|
||||||
return (
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<Suspense fallback={null}>
|
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
||||||
{/* 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)} />
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
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
|
||||||
@@ -35,7 +32,6 @@ 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 {
|
||||||
@@ -99,10 +95,8 @@ 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,
|
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||||
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
||||||
.mapboxgl-popup.trek-journey-popup .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);
|
||||||
@@ -114,24 +108,20 @@ 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,
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||||||
.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;
|
||||||
@@ -142,8 +132,7 @@ function ensureJourneyPopupStyle() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||||
.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;
|
||||||
@@ -154,8 +143,7 @@ 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,
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||||
.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;
|
||||||
@@ -206,29 +194,20 @@ 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, glProvider = 'mapbox-gl' },
|
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const stableTrail = trail || EMPTY_TRAIL
|
const stableTrail = trail || EMPTY_TRAIL
|
||||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
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)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
const mapRef = useRef<any | null>(null)
|
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||||||
// 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)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||||
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)
|
||||||
@@ -268,7 +247,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 gl.Popup({
|
popupRef.current = new mapboxgl.Popup({
|
||||||
closeButton: false,
|
closeButton: false,
|
||||||
closeOnClick: false,
|
closeOnClick: false,
|
||||||
closeOnMove: false,
|
closeOnMove: false,
|
||||||
@@ -281,7 +260,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) {
|
||||||
@@ -326,11 +305,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: enableMapbox3d ? 45 : 0,
|
pitch: mapbox3d ? 45 : 0,
|
||||||
duration: 600,
|
duration: 600,
|
||||||
})
|
})
|
||||||
} catch { /* map not yet ready */ }
|
} catch { /* map not yet ready */ }
|
||||||
}, [highlightMarker, enableMapbox3d])
|
}, [highlightMarker, mapbox3d])
|
||||||
|
|
||||||
const invalidateSize = useCallback(() => {
|
const invalidateSize = useCallback(() => {
|
||||||
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||||
@@ -341,46 +320,39 @@ 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 || (!isMapLibre && !mapboxToken)) return
|
if (!containerRef.current || !mapboxToken) return
|
||||||
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
|
mapboxgl.accessToken = mapboxToken
|
||||||
|
|
||||||
const items = buildItems(entries)
|
const items = buildItems(entries)
|
||||||
itemsRef.current = items
|
itemsRef.current = items
|
||||||
|
|
||||||
const bounds = new gl.LngLatBounds()
|
const bounds = new mapboxgl.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 mapOptions: Record<string, unknown> = {
|
const map = new mapboxgl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: glStyle,
|
style: mapboxStyle,
|
||||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||||
zoom: hasPoints ? 2 : 1,
|
zoom: hasPoints ? 2 : 1,
|
||||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
pitch: mapbox3d && 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 (enableMapbox3d) {
|
if (mapbox3d) {
|
||||||
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||||
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
if (supportsCustom3d(mapboxStyle)) 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 (glStyle === MAPBOX_DEFAULT_STYLE) {
|
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||||
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) {
|
||||||
@@ -411,7 +383,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 gl.Marker({ element: el, anchor: 'bottom' })
|
const marker = new mapboxgl.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) => {
|
||||||
@@ -428,7 +400,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: enableMapbox3d && fullScreen ? 45 : 0,
|
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
})
|
})
|
||||||
} catch { /* empty bounds */ }
|
} catch { /* empty bounds */ }
|
||||||
@@ -446,7 +418,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, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||||
|
|
||||||
// external activeMarkerId → highlight + flyTo
|
// external activeMarkerId → highlight + flyTo
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -459,15 +431,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: enableMapbox3d && fullScreen ? 45 : 0,
|
pitch: mapbox3d && 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, enableMapbox3d, fullScreen])
|
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||||||
|
|
||||||
if (!isMapLibre && !mapboxToken) {
|
if (!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' }}
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
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 GL planner map. The map can be rotated and
|
* Round compass pill for the Mapbox planner map. The Mapbox 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
|
||||||
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
* (Mapbox 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: CompassMap }) {
|
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
|
||||||
const [bearing, setBearing] = useState(() => map.getBearing())
|
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,36 +1,21 @@
|
|||||||
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). GL maps are best-effort offline — their
|
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
|
||||||
// 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 GL tile rules in vite.config.js), not prefetched.
|
// them online (see the mapbox-tiles rule 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.
|
||||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
|
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
|
||||||
: 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,35 +58,6 @@ 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),
|
||||||
@@ -206,25 +177,4 @@ 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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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'
|
||||||
@@ -11,7 +9,6 @@ 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'
|
||||||
@@ -57,9 +54,7 @@ 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
|
||||||
glProvider?: GlMapProvider
|
onMapReady?: (map: mapboxgl.Map | null) => void
|
||||||
// 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 {
|
||||||
@@ -96,8 +91,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 — GL map libraries ship
|
// Do NOT set `position: relative` here — mapbox-gl ships
|
||||||
// marker classes with `position: absolute` and rely on it. An inline
|
// `.mapboxgl-marker { position: absolute }` and relies 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
|
||||||
@@ -174,40 +169,29 @@ export function MapViewGL({
|
|||||||
pois = [],
|
pois = [],
|
||||||
onPoiClick,
|
onPoiClick,
|
||||||
onViewportChange,
|
onViewportChange,
|
||||||
glProvider = 'mapbox-gl',
|
|
||||||
onMapReady,
|
onMapReady,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
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)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
const mapRef = useRef<any | null>(null)
|
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||||
// 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
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||||
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.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||||
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)
|
||||||
@@ -220,25 +204,23 @@ export function MapViewGL({
|
|||||||
onClickRefs.current.map = onMapClick
|
onClickRefs.current.map = onMapClick
|
||||||
onClickRefs.current.context = onMapContextMenu
|
onClickRefs.current.context = onMapContextMenu
|
||||||
|
|
||||||
// Build/rebuild the map on provider/style/token/3d change
|
// Build/rebuild the map on style/token/3d change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
|
if (!containerRef.current || !mapboxToken) return
|
||||||
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
|
mapboxgl.accessToken = mapboxToken
|
||||||
|
|
||||||
const mapOptions: Record<string, unknown> = {
|
const map = new mapboxgl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: glStyle,
|
style: mapboxStyle,
|
||||||
center: [center[1], center[0]],
|
center: [center[1], center[0]],
|
||||||
zoom,
|
zoom,
|
||||||
pitch: enableMapbox3d ? 45 : 0,
|
pitch: mapbox3d ? 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 gl.Popup({
|
popupRef.current = new mapboxgl.Popup({
|
||||||
closeButton: false,
|
closeButton: false,
|
||||||
closeOnClick: false,
|
closeOnClick: false,
|
||||||
offset: 18,
|
offset: 18,
|
||||||
@@ -252,12 +234,12 @@ export function MapViewGL({
|
|||||||
;(window as any).__trek_map = map
|
;(window as any).__trek_map = map
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
if (enableMapbox3d) {
|
if (mapbox3d) {
|
||||||
// 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(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||||
if (supportsCustom3d(glStyle)) {
|
if (supportsCustom3d(mapboxStyle)) {
|
||||||
const dark = document.documentElement.classList.contains('dark')
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
addCustom3dBuildings(map, dark)
|
addCustom3dBuildings(map, dark)
|
||||||
}
|
}
|
||||||
@@ -270,7 +252,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 (glStyle === MAPBOX_DEFAULT_STYLE) {
|
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||||
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
|
||||||
@@ -316,7 +298,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, .maplibregl-marker')) return // markers handle their own click
|
if (t.closest('.mapboxgl-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
|
||||||
@@ -327,7 +309,7 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
map.on('moveend', emitViewport)
|
map.on('moveend', emitViewport)
|
||||||
map.once('idle', emitViewport)
|
map.once('idle', emitViewport)
|
||||||
// In the GL map the right mouse button is reserved for the
|
// In the mapbox-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()
|
||||||
@@ -374,9 +356,7 @@ export function MapViewGL({
|
|||||||
const ll = marker.getLngLat()
|
const ll = marker.getLngLat()
|
||||||
let alt = 0
|
let alt = 0
|
||||||
try {
|
try {
|
||||||
const e = typeof map.queryTerrainElevation === 'function'
|
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
||||||
? 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
|
||||||
@@ -388,9 +368,7 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame
|
map.on('render', syncMarkerAltitudes)
|
||||||
// listener entirely for MapLibre and flat mapbox styles.
|
|
||||||
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener('mousedown', onAuxDown)
|
canvas.removeEventListener('mousedown', onAuxDown)
|
||||||
@@ -411,17 +389,7 @@ export function MapViewGL({
|
|||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
setMapReady(false)
|
setMapReady(false)
|
||||||
}
|
}
|
||||||
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
|
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on 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.
|
||||||
@@ -521,12 +489,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 gl.Marker({ element: el, anchor: 'center' })
|
const m = new mapboxgl.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, mapReady, glProvider])
|
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||||
|
|
||||||
// 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).
|
||||||
@@ -543,10 +511,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 gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||||
poiMarkersRef.current.push(m)
|
poiMarkersRef.current.push(m)
|
||||||
}
|
}
|
||||||
}, [pois, mapReady, glProvider])
|
}, [pois, mapReady])
|
||||||
|
|
||||||
// Update route geojson
|
// Update route geojson
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -610,7 +578,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,
|
||||||
@@ -618,7 +586,7 @@ export function MapViewGL({
|
|||||||
showEndpointLabels,
|
showEndpointLabels,
|
||||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||||
})
|
})
|
||||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
|
||||||
|
|
||||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||||
const paddingOpts = useMemo(() => {
|
const paddingOpts = useMemo(() => {
|
||||||
@@ -638,14 +606,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 gl.LngLatBounds()
|
const bounds = new mapboxgl.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: enableMapbox3d ? 45 : 0,
|
pitch: mapbox3d ? 45 : 0,
|
||||||
duration: 400,
|
duration: 400,
|
||||||
})
|
})
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
@@ -664,7 +632,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: enableMapbox3d ? 45 : 0,
|
pitch: mapbox3d ? 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
|
||||||
@@ -672,7 +640,7 @@ export function MapViewGL({
|
|||||||
padding: paddingOpts,
|
padding: paddingOpts,
|
||||||
})
|
})
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [selectedPlaceId, mapbox3d]) // 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(() => {
|
||||||
@@ -695,7 +663,7 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
if (!userPosition) return
|
if (!userPosition) return
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any)
|
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
||||||
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
|
||||||
@@ -711,9 +679,9 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
if (map.loaded()) apply()
|
if (map.loaded()) apply()
|
||||||
else map.once('load', apply)
|
else map.once('load', apply)
|
||||||
}, [userPosition, trackingMode, glProvider])
|
}, [userPosition, trackingMode])
|
||||||
|
|
||||||
if (!isMapLibre && !mapboxToken) {
|
if (!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">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
calculateSegments,
|
calculateSegments,
|
||||||
optimizeRoute,
|
optimizeRoute,
|
||||||
generateGoogleMapsUrl,
|
generateGoogleMapsUrl,
|
||||||
withHotelBookends,
|
|
||||||
} from './RouteCalculator'
|
} from './RouteCalculator'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
@@ -242,46 +241,3 @@ describe('generateGoogleMapsUrl', () => {
|
|||||||
expect(result).toContain('48.86,2.36')
|
expect(result).toContain('48.86,2.36')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
|
|
||||||
|
|
||||||
describe('withHotelBookends', () => {
|
|
||||||
const hotel = { lat: 1, lng: 1 }
|
|
||||||
const a = { lat: 2, lng: 2 }
|
|
||||||
const b = { lat: 3, lng: 3 }
|
|
||||||
const evening = { lat: 4, lng: 4 }
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
|
|
||||||
const runs = [[a, b]]
|
|
||||||
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
|
|
||||||
const runs = [[a, b]]
|
|
||||||
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
|
|
||||||
[hotel, a],
|
|
||||||
[a, b],
|
|
||||||
[b, evening],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
|
|
||||||
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
|
|
||||||
[hotel, a],
|
|
||||||
[a, evening],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
|
|
||||||
const runs = [[a, b]]
|
|
||||||
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
|
|
||||||
const runs = [[a, b]]
|
|
||||||
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
|
|
||||||
[hotel, a],
|
|
||||||
[a, b],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||||
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'
|
||||||
|
|
||||||
@@ -62,34 +60,13 @@ export async function calculateRoute(
|
|||||||
coordinates,
|
coordinates,
|
||||||
distance,
|
distance,
|
||||||
duration,
|
duration,
|
||||||
distanceText: formatRouteDistance(distance),
|
distanceText: formatDistance(distance),
|
||||||
durationText: formatDuration(duration),
|
durationText: formatDuration(duration),
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(drivingDuration),
|
drivingText: formatDuration(drivingDuration),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the
|
|
||||||
* day's activity runs, so the drawn route starts and ends at the day's accommodation
|
|
||||||
* (matching the sidebar's hotel connectors). A bookend is only added when both its
|
|
||||||
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
|
|
||||||
* untouched. The shared first/last waypoint is repeated so the polylines join.
|
|
||||||
*/
|
|
||||||
export function withHotelBookends(
|
|
||||||
runs: Waypoint[][],
|
|
||||||
firstWay: Waypoint | undefined,
|
|
||||||
lastWay: Waypoint | undefined,
|
|
||||||
startHotel: Waypoint | null,
|
|
||||||
endHotel: Waypoint | null,
|
|
||||||
): Waypoint[][] {
|
|
||||||
const out: Waypoint[][] = []
|
|
||||||
if (startHotel && firstWay) out.push([startHotel, firstWay])
|
|
||||||
out.push(...runs)
|
|
||||||
if (endHotel && lastWay) out.push([lastWay, endHotel])
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||||
const valid = places.filter((p) => p.lat && p.lng)
|
const valid = places.filter((p) => p.lat && p.lng)
|
||||||
if (valid.length === 0) return null
|
if (valid.length === 0) return null
|
||||||
@@ -220,7 +197,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: formatRouteDistance(leg.distance),
|
distanceText: formatDistance(leg.distance),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -240,9 +217,7 @@ export async function calculateRouteWithLegs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
// The cached result carries formatted leg distances, so the active distance unit is
|
const cacheKey = `${profile}:${coords}`
|
||||||
// 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
|
||||||
|
|
||||||
@@ -269,7 +244,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: formatRouteDistance(leg.distance),
|
distanceText: formatDistance(leg.distance),
|
||||||
durationText: formatDuration(leg.duration),
|
durationText: formatDuration(leg.duration),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,16 +259,11 @@ export async function calculateRouteWithLegs(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDistanceUnit(): DistanceUnit {
|
function formatDistance(meters: number): string {
|
||||||
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
if (meters < 1000) {
|
||||||
}
|
|
||||||
|
|
||||||
function formatRouteDistance(meters: number): string {
|
|
||||||
const unit = getDistanceUnit()
|
|
||||||
if (unit === 'metric' && meters < 1000) {
|
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
}
|
}
|
||||||
return formatDistance(meters / 1000, unit)
|
return `${(meters / 1000).toFixed(1)} km`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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,13 +1,6 @@
|
|||||||
import type mapboxgl from 'mapbox-gl'
|
import 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.
|
||||||
@@ -73,10 +66,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, MarkerCtor: MarkerConstructor): LocationMarkerHandle {
|
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
|
||||||
ensurePulseStyle()
|
ensurePulseStyle()
|
||||||
const { root, cone } = buildLocationEl()
|
const { root, cone } = buildLocationEl()
|
||||||
const marker = new MarkerCtor({ element: root, anchor: 'center' })
|
const marker = new mapboxgl.Marker({ 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 type mapboxgl from 'mapbox-gl'
|
import 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,29 +220,18 @@ 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 MarkerCtor: MarkerConstructor
|
private endpointMarkers: mapboxgl.Marker[] = []
|
||||||
private endpointMarkers: GlMarker[] = []
|
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
|
||||||
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, MarkerCtor: MarkerConstructor) {
|
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
|
||||||
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)
|
||||||
@@ -361,7 +350,7 @@ export class ReservationMapboxOverlay {
|
|||||||
this.opts.onEndpointClick?.(item.res.id)
|
this.opts.onEndpointClick?.(item.res.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const marker = new this.MarkerCtor({ element: node, anchor: 'center' })
|
const marker = new mapboxgl.Marker({ 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)
|
||||||
|
|||||||
@@ -323,28 +323,6 @@ describe('downloadTripPDF', () => {
|
|||||||
expect(photoCalled).toBe(true)
|
expect(photoCalled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
|
|
||||||
let fetchedId: string | null = null
|
|
||||||
server.use(
|
|
||||||
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
|
|
||||||
fetchedId = params.placeId as string
|
|
||||||
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
// The assignment projection drops osm_id; the full place in `places` carries it.
|
|
||||||
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
|
|
||||||
const args = {
|
|
||||||
...richArgs,
|
|
||||||
places: [osmPlace],
|
|
||||||
assignments: {
|
|
||||||
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
|
|
||||||
} as any,
|
|
||||||
}
|
|
||||||
await downloadTripPDF(args)
|
|
||||||
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
|
|
||||||
expect(fetchedId).toBe('node/240109189')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
||||||
const args = {
|
const args = {
|
||||||
...minimalArgs,
|
...minimalArgs,
|
||||||
|
|||||||
@@ -97,29 +97,21 @@ function dayCost(assignments, dayId, locale) {
|
|||||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fetch place photos for all assigned places.
|
// Pre-fetch Google Place photos for all assigned places
|
||||||
// Assignment places are a server-side projection that drops osm_id, so we recover
|
async function fetchPlacePhotos(assignments: AssignmentsMap) {
|
||||||
// the full place from the trip's places pool and key the photo off the same id the
|
|
||||||
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
|
|
||||||
// places fell back to category icons in the PDF even though they show photos in-app.
|
|
||||||
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
|
|
||||||
const photoMap = {} // placeId → photoUrl
|
const photoMap = {} // placeId → photoUrl
|
||||||
// The assignment projection drops osm_id, so recover it from the full places pool.
|
|
||||||
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
|
|
||||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||||
|
|
||||||
const toFetch = unique
|
// Assignment places are a server-side projection that omits osm_id, so photo
|
||||||
.map(p => ({ p, osm_id: osmById.get(p.id) }))
|
// pre-fetch keys off the google_place_id that the projection does carry.
|
||||||
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
|
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
toFetch.map(async ({ p, osm_id }) => {
|
toFetch.map(async (place) => {
|
||||||
// Same key the app UI uses: google_place_id || osm_id || coords.
|
|
||||||
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
|
|
||||||
try {
|
try {
|
||||||
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
|
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
|
||||||
if (data.photoUrl) photoMap[p.id] = data.photoUrl
|
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -149,8 +141,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||||
const accommodations = await accommodationsApi.list(trip.id);
|
const accommodations = await accommodationsApi.list(trip.id);
|
||||||
|
|
||||||
// Pre-fetch place photos (Google, OSM and coords-only places)
|
// Pre-fetch place photos from Google
|
||||||
const photoMap = await fetchPlacePhotos(assignments, places)
|
const photoMap = await fetchPlacePhotos(assignments)
|
||||||
|
|
||||||
const totalAssigned = new Set(
|
const totalAssigned = new Set(
|
||||||
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
||||||
|
|||||||
@@ -174,9 +174,7 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
|
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
|
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
|
||||||
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
|
|
||||||
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
|
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/trips/1/packing/99', () => {
|
http.delete('/api/trips/1/packing/99', () => {
|
||||||
@@ -1417,83 +1415,4 @@ describe('PackingListPanel', () => {
|
|||||||
expect(clickSpy).toHaveBeenCalled();
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
clickSpy.mockRestore();
|
clickSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
|
|
||||||
// handleDeleteItem decides "last in category" from the rendered list.
|
|
||||||
seedStore(useTripStore, { packingItems: [item] });
|
|
||||||
let deleted = false;
|
|
||||||
let putBody: Record<string, unknown> | null = null;
|
|
||||||
server.use(
|
|
||||||
http.delete('/api/trips/1/packing/99', () => {
|
|
||||||
deleted = true;
|
|
||||||
return HttpResponse.json({ success: true });
|
|
||||||
}),
|
|
||||||
http.put('/api/trips/1/packing/99', async ({ request }) => {
|
|
||||||
putBody = await request.json() as Record<string, unknown>;
|
|
||||||
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
render(<PackingListPanel tripId={1} items={[item]} />);
|
|
||||||
|
|
||||||
await user.click(screen.getByTitle('Delete'));
|
|
||||||
|
|
||||||
// The row is updated in place (same id) rather than deleted, so colour/position hold.
|
|
||||||
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
|
|
||||||
expect(deleted).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
|
||||||
seedStore(useTripStore, { packingItems: [placeholder] });
|
|
||||||
let deleted = false;
|
|
||||||
let converted = false;
|
|
||||||
server.use(
|
|
||||||
http.delete('/api/trips/1/packing/5', () => {
|
|
||||||
deleted = true;
|
|
||||||
return HttpResponse.json({ success: true });
|
|
||||||
}),
|
|
||||||
http.put('/api/trips/1/packing/5', () => {
|
|
||||||
converted = true;
|
|
||||||
return HttpResponse.json({ item: placeholder });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
|
||||||
|
|
||||||
await user.click(screen.getByTitle('Delete'));
|
|
||||||
|
|
||||||
await waitFor(() => expect(deleted).toBe(true));
|
|
||||||
// It is the placeholder itself — it must be removed, not re-converted.
|
|
||||||
expect(converted).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
|
||||||
seedStore(useTripStore, { packingItems: [placeholder] });
|
|
||||||
let posted = false;
|
|
||||||
let putBody: Record<string, unknown> | null = null;
|
|
||||||
server.use(
|
|
||||||
http.post('/api/trips/1/packing', () => {
|
|
||||||
posted = true;
|
|
||||||
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
|
|
||||||
}),
|
|
||||||
http.put('/api/trips/1/packing/5', async ({ request }) => {
|
|
||||||
putBody = await request.json() as Record<string, unknown>;
|
|
||||||
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
|
||||||
|
|
||||||
// Open the category's inline "Add item" and add a real entry.
|
|
||||||
await user.click(screen.getByText('Add item'));
|
|
||||||
const input = await screen.findByPlaceholderText('Item name...');
|
|
||||||
await user.type(input, 'Tent');
|
|
||||||
await user.keyboard('{Enter}');
|
|
||||||
|
|
||||||
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
|
|
||||||
expect(posted).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface KategorieGruppeProps {
|
|||||||
allCategories: string[]
|
allCategories: string[]
|
||||||
onRename: (oldName: string, newName: string) => Promise<void>
|
onRename: (oldName: string, newName: string) => Promise<void>
|
||||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||||
onDeleteItem: (item: PackingItem) => Promise<void>
|
|
||||||
onAddItem: (category: string, name: string) => Promise<void>
|
onAddItem: (category: string, name: string) => Promise<void>
|
||||||
assignees: CategoryAssignee[]
|
assignees: CategoryAssignee[]
|
||||||
tripMembers: TripMember[]
|
tripMembers: TripMember[]
|
||||||
@@ -29,7 +28,7 @@ interface KategorieGruppeProps {
|
|||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||||
const [offen, setOffen] = useState(true)
|
const [offen, setOffen] = useState(true)
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [editKatName, setEditKatName] = useState(kategorie)
|
const [editKatName, setEditKatName] = useState(kategorie)
|
||||||
@@ -232,7 +231,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
{offen && (
|
{offen && (
|
||||||
<div style={{ padding: '4px 4px 6px' }}>
|
<div style={{ padding: '4px 4px 6px' }}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||||
))}
|
))}
|
||||||
{/* Inline add item */}
|
{/* Inline add item */}
|
||||||
{canEdit && (showAddItem ? (
|
{canEdit && (showAddItem ? (
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ interface ArtikelZeileProps {
|
|||||||
tripId: number
|
tripId: number
|
||||||
categories: string[]
|
categories: string[]
|
||||||
onCategoryChange: () => void
|
onCategoryChange: () => void
|
||||||
onDelete?: (item: PackingItem) => Promise<void>
|
|
||||||
bagTrackingEnabled?: boolean
|
bagTrackingEnabled?: boolean
|
||||||
bags?: PackingBag[]
|
bags?: PackingBag[]
|
||||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||||
@@ -44,9 +43,6 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
// The panel routes deletion through onDelete so an emptied custom category
|
|
||||||
// keeps its placeholder; fall back to a plain delete when used standalone.
|
|
||||||
if (onDelete) { await onDelete(item); return }
|
|
||||||
try { await deletePackingItem(tripId, item.id) }
|
try { await deletePackingItem(tripId, item.id) }
|
||||||
catch { toast.error(t('packing.toast.deleteError')) }
|
catch { toast.error(t('packing.toast.deleteError')) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
|||||||
|
|
||||||
export function PackingList(S: PackingState) {
|
export function PackingList(S: PackingState) {
|
||||||
const {
|
const {
|
||||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
|
||||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
||||||
} = S
|
} = S
|
||||||
@@ -31,7 +31,6 @@ export function PackingList(S: PackingState) {
|
|||||||
allCategories={allCategories}
|
allCategories={allCategories}
|
||||||
onRename={handleRenameCategory}
|
onRename={handleRenameCategory}
|
||||||
onDeleteAll={handleDeleteCategory}
|
onDeleteAll={handleDeleteCategory}
|
||||||
onDeleteItem={handleDeleteItem}
|
|
||||||
onAddItem={handleAddItemToCategory}
|
onAddItem={handleAddItemToCategory}
|
||||||
assignees={categoryAssignees[kat] || []}
|
assignees={categoryAssignees[kat] || []}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { packingApi, tripsApi } from '../../api/client'
|
import { packingApi, tripsApi } from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import type { PackingItem, PackingBag } from '../../types'
|
import type { PackingItem, PackingBag } from '../../types'
|
||||||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
import { BAG_COLORS } from './packingListPanel.constants'
|
||||||
import { parseImportLines } from './packingListPanel.helpers'
|
import { parseImportLines } from './packingListPanel.helpers'
|
||||||
|
|
||||||
export interface TripMember {
|
export interface TripMember {
|
||||||
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
|
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
const canEdit = can('packing_edit', trip)
|
const canEdit = can('packing_edit', trip)
|
||||||
@@ -106,45 +106,10 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
|
|
||||||
const handleAddItemToCategory = async (category: string, name: string) => {
|
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||||
try {
|
try {
|
||||||
// Reuse the '...' placeholder slot when the category already has one, so a
|
await addPackingItem(tripId, { name, category })
|
||||||
// freshly-emptied category keeps its position (and therefore its colour)
|
|
||||||
// instead of the new item being appended to the end of the list.
|
|
||||||
const placeholder = useTripStore.getState().packingItems.find(
|
|
||||||
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
|
|
||||||
)
|
|
||||||
if (placeholder) {
|
|
||||||
await updatePackingItem(tripId, placeholder.id, { name })
|
|
||||||
} else {
|
|
||||||
await addPackingItem(tripId, { name, category })
|
|
||||||
}
|
|
||||||
} catch { toast.error(t('packing.toast.addError')) }
|
} catch { toast.error(t('packing.toast.addError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting an item from a row. When it is the last item of a user-created
|
|
||||||
// category, turn that row back into the '...' placeholder in place rather than
|
|
||||||
// deleting it (#1289). Updating the row keeps its id, list position and colour,
|
|
||||||
// so the category neither disappears nor jumps to the end. The default
|
|
||||||
// (uncategorized) group and the placeholder row itself are deleted normally —
|
|
||||||
// removing the placeholder is how an empty category is dismissed.
|
|
||||||
const handleDeleteItem = async (item: PackingItem) => {
|
|
||||||
const category = item.category
|
|
||||||
const isLastInCategory = !!category
|
|
||||||
&& item.name !== PACKING_PLACEHOLDER_NAME
|
|
||||||
&& !items.some(i => i.id !== item.id && i.category === category)
|
|
||||||
try {
|
|
||||||
if (isLastInCategory) {
|
|
||||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
|
||||||
await updatePackingItem(tripId, item.id, {
|
|
||||||
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await deletePackingItem(tripId, item.id)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t('packing.toast.deleteError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddNewCategory = async () => {
|
const handleAddNewCategory = async () => {
|
||||||
if (!newCatName.trim()) return
|
if (!newCatName.trim()) return
|
||||||
let catName = newCatName.trim()
|
let catName = newCatName.trim()
|
||||||
@@ -343,7 +308,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
||||||
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||||
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
||||||
|
|||||||
@@ -168,34 +168,6 @@ 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', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
@@ -35,7 +35,6 @@ 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
|
||||||
@@ -155,9 +154,6 @@ 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())
|
||||||
@@ -415,30 +411,25 @@ 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 bookends = day && optimizeFromAccommodation !== false
|
const { morning: startHotel, evening: endHotel } =
|
||||||
? getDayBookendHotels(day, days, accommodations)
|
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
|
||||||
: null
|
|
||||||
const startHotel = bookends?.morning
|
|
||||||
const endHotel = bookends?.evening
|
|
||||||
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
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. Track
|
// legs connect even when the day starts or ends with a booking rather than a place.
|
||||||
// whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1
|
const wayPts: { lat: number; lng: number }[] = []
|
||||||
// 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, isPlace: true })
|
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
} 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, isPlace: false })
|
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
|
||||||
if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false })
|
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const firstWay = wayPts[0]
|
const firstWay = wayPts[0]
|
||||||
const lastWay = wayPts[wayPts.length - 1]
|
const lastWay = wayPts[wayPts.length - 1]
|
||||||
const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere))
|
const wantTop = !!(startHotel && firstWay)
|
||||||
const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight))
|
const wantBottom = !!(endHotel && lastWay)
|
||||||
|
|
||||||
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
||||||
|
|
||||||
@@ -474,7 +465,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, distanceUnit])
|
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -1055,9 +1046,6 @@ 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,
|
||||||
@@ -1243,16 +1231,6 @@ 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]
|
||||||
@@ -1617,17 +1595,14 @@ 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 => {
|
onContextMenu={e => ctxMenu.open(e, [
|
||||||
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
|
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||||
ctxMenu.open(e, [
|
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, 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') },
|
||||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
{ divider: true },
|
||||||
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||||
{ 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)'
|
||||||
@@ -2176,8 +2151,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend, #1330) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
|
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
|
||||||
<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
|
||||||
@@ -2193,28 +2168,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
<RouteIcon size={12} strokeWidth={2} />
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
{t('dayplan.route')}
|
{t('dayplan.route')}
|
||||||
</button>
|
</button>
|
||||||
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
|
|
||||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
||||||
}}
|
|
||||||
aria-label={t('planner.openGoogleMaps')}
|
|
||||||
title={t('planner.openGoogleMaps')}
|
|
||||||
className="bg-transparent text-content-secondary"
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
|
||||||
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
|
||||||
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
|
||||||
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -399,38 +399,17 @@ describe('PlaceFormModal', () => {
|
|||||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
|
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
|
||||||
// Times are per day-assignment; editing a pool place with no day in context
|
|
||||||
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
|
|
||||||
const place = buildPlace({ name: 'Test' });
|
const place = buildPlace({ name: 'Test' });
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
// Time pickers are rendered when editing
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
|
|
||||||
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
|
|
||||||
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
|
|
||||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
|
|
||||||
// The pool Place carries no times — they live on the day-assignment. Opening the
|
|
||||||
// editor with an assignmentId must hydrate the fields from assignment.place, not
|
|
||||||
// the (timeless) pool place that the Places panel passes in.
|
|
||||||
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
|
|
||||||
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
|
|
||||||
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
|
|
||||||
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
|
|
||||||
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
|
|
||||||
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
||||||
// Build an assignment whose place has end_time before place_time
|
// Build a place with end_time before place_time
|
||||||
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
||||||
const assignment = buildAssignment({ id: 11, day_id: 5, place });
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
|
|
||||||
|
|
||||||
// hasTimeError = true → submit button disabled
|
// hasTimeError = true → submit button disabled
|
||||||
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
||||||
|
|||||||
@@ -92,11 +92,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
if (place) {
|
||||||
// Times are stored per day-assignment, not on the pool place. When an
|
|
||||||
// assignment is in context (itinerary edit, or a single-assignment pool
|
|
||||||
// edit) read the times off its embedded place; fall back to the place prop.
|
|
||||||
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
|
||||||
const timeSource = assignment?.place ?? place
|
|
||||||
setForm({
|
setForm({
|
||||||
name: place.name || '',
|
name: place.name || '',
|
||||||
description: place.description || '',
|
description: place.description || '',
|
||||||
@@ -104,8 +99,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lat: place.lat != null ? String(place.lat) : '',
|
lat: place.lat != null ? String(place.lat) : '',
|
||||||
lng: place.lng != null ? String(place.lng) : '',
|
lng: place.lng != null ? String(place.lng) : '',
|
||||||
category_id: place.category_id != null ? String(place.category_id) : '',
|
category_id: place.category_id != null ? String(place.category_id) : '',
|
||||||
place_time: timeSource.place_time || '',
|
place_time: place.place_time || '',
|
||||||
end_time: timeSource.end_time || '',
|
end_time: place.end_time || '',
|
||||||
notes: place.notes || '',
|
notes: place.notes || '',
|
||||||
transport_mode: place.transport_mode || 'walking',
|
transport_mode: place.transport_mode || 'walking',
|
||||||
website: place.website || '',
|
website: place.website || '',
|
||||||
@@ -126,10 +121,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
}
|
}
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
setDuplicateWarning(null)
|
setDuplicateWarning(null)
|
||||||
// dayAssignments is a fresh array each render; read it at open-time only and
|
}, [place, prefillCoords, isOpen])
|
||||||
// re-run on identity changes (place/assignmentId/open), not on every render.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [place, prefillCoords, isOpen, assignmentId])
|
|
||||||
|
|
||||||
// Derive location bias bounding box from the trip's existing places
|
// Derive location bias bounding box from the trip's existing places
|
||||||
const places = useTripStore((s) => s.places)
|
const places = useTripStore((s) => s.places)
|
||||||
@@ -217,7 +209,6 @@ 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('')
|
||||||
@@ -242,7 +233,6 @@ 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,
|
||||||
@@ -738,11 +728,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time is per day-assignment: only shown when a single assignment is in
|
{/* Time — only shown when editing, not when creating */}
|
||||||
context (itinerary edit, or a single-assignment pool edit). Hidden when
|
{place && (
|
||||||
creating, and for unassigned / multi-day pool edits where a single time
|
|
||||||
is ambiguous and wouldn't persist. */}
|
|
||||||
{place && assignmentId && (
|
|
||||||
<TimeSection
|
<TimeSection
|
||||||
form={form}
|
form={form}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
|
|||||||
@@ -618,22 +618,6 @@ 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', () => {
|
||||||
@@ -702,3 +686,4 @@ describe('PlaceInspector', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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()
|
||||||
|
|
||||||
@@ -124,7 +122,6 @@ 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)
|
||||||
@@ -165,11 +162,6 @@ 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)
|
||||||
|
|
||||||
@@ -282,8 +274,7 @@ 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>
|
||||||
|
|
||||||
@@ -297,10 +288,14 @@ 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')} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{googleMapsUrl && (
|
{googleDetails?.google_maps_url && (
|
||||||
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_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>} />
|
||||||
@@ -687,7 +682,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, distanceUnit }: any) {
|
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: 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 && (
|
||||||
@@ -780,20 +775,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" />
|
||||||
{formatDistance(distKm, distanceUnit)}
|
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
|
||||||
</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" />
|
||||||
{formatElevation(maxEle, distanceUnit)}
|
{Math.round(maxEle)} m
|
||||||
</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" />
|
||||||
{formatElevation(minEle, distanceUnit)}
|
{Math.round(minEle)} m
|
||||||
</div>
|
</div>
|
||||||
<div className="text-content-muted" style={{ fontSize: 12 }}>
|
<div className="text-content-muted" style={{ fontSize: 12 }}>
|
||||||
↑{formatElevation(totalUp, distanceUnit)} ↓{formatElevation(totalDown, distanceUnit)}
|
↑{Math.round(totalUp)} m ↓{Math.round(totalDown)} m
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -124,40 +124,6 @@ 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, registerPlaceRow,
|
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
||||||
} = 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,7 +44,6 @@ export function PlacesList(S: SidebarState) {
|
|||||||
onAssignToDay={onAssignToDay}
|
onAssignToDay={onAssignToDay}
|
||||||
toggleSelected={toggleSelected}
|
toggleSelected={toggleSelected}
|
||||||
setDayPickerPlace={setDayPickerPlace}
|
setDayPickerPlace={setDayPickerPlace}
|
||||||
registerPlaceRow={registerPlaceRow}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,21 +21,17 @@ 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, registerPlaceRow,
|
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
||||||
}: 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))
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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,7 +9,6 @@ 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
|
||||||
@@ -60,8 +59,6 @@ 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
|
||||||
@@ -200,28 +197,6 @@ 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)
|
||||||
|
|
||||||
@@ -235,12 +210,11 @@ 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') },
|
||||||
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_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') },
|
||||||
{ 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) },
|
||||||
])
|
])
|
||||||
@@ -260,7 +234,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, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,22 +150,6 @@ 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,14 +6,12 @@ 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)
|
||||||
|
|
||||||
@@ -30,10 +28,6 @@ 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 */}
|
||||||
@@ -206,37 +200,6 @@ 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,22 +1,14 @@
|
|||||||
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, Globe2 } from 'lucide-react'
|
import { Map, Save, Layers, Box, ChevronDown, Check } 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 GlMapPreview from './MapboxPreview'
|
import MapboxPreview 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
|
||||||
@@ -31,6 +23,25 @@ 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> = {
|
||||||
@@ -48,7 +59,6 @@ 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 }) {
|
||||||
@@ -60,11 +70,10 @@ function TagChip({ tag }: { tag: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) {
|
function StyleDropdown({ value, onChange }: { value: string; 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
|
||||||
@@ -75,10 +84,7 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
|||||||
return () => document.removeEventListener('mousedown', onDoc)
|
return () => document.removeEventListener('mousedown', onDoc)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const selected = presets.find(p => p.url === value)
|
const selected = MAPBOX_STYLE_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">
|
||||||
@@ -89,11 +95,11 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
|||||||
>
|
>
|
||||||
<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 : placeholder}
|
{selected ? selected.name : t('settings.mapStylePlaceholder')}
|
||||||
</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>
|
||||||
@@ -101,7 +107,7 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
|||||||
</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">
|
||||||
{presets.map(preset => {
|
{MAPBOX_STYLE_PRESETS.map(preset => {
|
||||||
const isActive = preset.url === value
|
const isActive = preset.url === value
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -112,7 +118,7 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
|||||||
>
|
>
|
||||||
<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>
|
||||||
@@ -124,34 +130,17 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Provider = 'leaflet' | GlMapProvider
|
type Provider = 'leaflet' | 'mapbox-gl'
|
||||||
|
|
||||||
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>(initialProvider)
|
const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet')
|
||||||
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>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
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)
|
||||||
@@ -159,11 +148,10 @@ 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(() => {
|
||||||
const nextProvider = normalizeProvider(settings.map_provider)
|
setProvider((settings.map_provider as Provider) || 'leaflet')
|
||||||
setProvider(nextProvider)
|
|
||||||
setMapTileUrl(settings.map_tile_url || '')
|
setMapTileUrl(settings.map_tile_url || '')
|
||||||
setMapboxToken(settings.mapbox_access_token || '')
|
setMapboxToken(settings.mapbox_access_token || '')
|
||||||
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
|
setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
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)
|
||||||
@@ -198,15 +186,11 @@ 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,
|
||||||
...stylePatch,
|
mapbox_style: mapboxStyle,
|
||||||
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)),
|
||||||
@@ -224,20 +208,16 @@ 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-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeProvider('leaflet')}
|
onClick={() => setProvider('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'
|
||||||
@@ -252,7 +232,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeProvider('mapbox-gl')}
|
onClick={() => setProvider('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'
|
||||||
@@ -272,24 +252,6 @@ 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')}
|
||||||
@@ -319,10 +281,9 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GL settings */}
|
{/* Mapbox GL settings */}
|
||||||
{provider !== 'leaflet' && (
|
{provider === 'mapbox-gl' && (
|
||||||
<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
|
||||||
@@ -339,27 +300,24 @@ 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} provider={provider} onChange={setMapboxStyle} />
|
<StyleDropdown value={mapboxStyle} 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={defaultStyleForProvider(provider)}
|
placeholder="mapbox://styles/mapbox/standard"
|
||||||
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">
|
||||||
{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
|
{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'
|
||||||
@@ -396,8 +354,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -427,9 +383,8 @@ 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 !== 'leaflet' ? (
|
{provider === 'mapbox-gl' ? (
|
||||||
<GlMapPreview
|
<MapboxPreview
|
||||||
provider={provider}
|
|
||||||
token={mapboxToken}
|
token={mapboxToken}
|
||||||
style={mapboxStyle}
|
style={mapboxStyle}
|
||||||
lat={parseFloat(String(defaultLat)) || 48.8566}
|
lat={parseFloat(String(defaultLat)) || 48.8566}
|
||||||
@@ -437,8 +392,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={provider === 'mapbox-gl' && mapbox3d && supports3d}
|
enable3d={mapbox3d && supports3d}
|
||||||
quality={provider === 'mapbox-gl' && mapboxQuality}
|
quality={mapboxQuality}
|
||||||
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
|
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
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 {
|
||||||
provider?: GlMapProvider
|
token: string
|
||||||
token?: string
|
|
||||||
style: string
|
style: string
|
||||||
lat: number
|
lat: number
|
||||||
lng: number
|
lng: number
|
||||||
@@ -18,44 +14,37 @@ interface Props {
|
|||||||
onClick?: (latlng: { lat: number; lng: number }) => void
|
onClick?: (latlng: { lat: number; lng: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
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 || (!isMapLibre && !token)) return
|
if (!containerRef.current || !token) return
|
||||||
if (!isMapLibre) mapboxgl.accessToken = token
|
mapboxgl.accessToken = token
|
||||||
|
|
||||||
const mapOptions: Record<string, unknown> = {
|
const map = new mapboxgl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: glStyle,
|
style,
|
||||||
center: [lng, lat],
|
center: [lng, lat],
|
||||||
zoom,
|
zoom,
|
||||||
pitch: enableMapbox3d ? 45 : 0,
|
pitch: enable3d ? 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 (enableMapbox3d) {
|
if (enable3d) {
|
||||||
if (!isStandardFamily(glStyle)) addTerrainAndSky(map)
|
if (!isStandardFamily(style)) addTerrainAndSky(map)
|
||||||
if (supportsCustom3d(glStyle)) {
|
if (supportsCustom3d(style)) {
|
||||||
const dark = document.documentElement.classList.contains('dark')
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
addCustom3dBuildings(map, dark)
|
addCustom3dBuildings(map, dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
if (style === 'mapbox://styles/mapbox/standard') {
|
||||||
try { map.setTerrain(null) } catch { /* noop */ }
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -68,7 +57,7 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
|
|||||||
try { map.remove() } catch { /* noop */ }
|
try { map.remove() } catch { /* noop */ }
|
||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
}
|
}
|
||||||
}, [provider, token, glStyle, enableMapbox3d, quality])
|
}, [token, style, enable3d, 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(() => {
|
||||||
@@ -76,7 +65,7 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
|
|||||||
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 (!isMapLibre && !token) {
|
if (!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,17 +62,16 @@ 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 });
|
||||||
if (notice.cta.dismissOnAction !== false) onDismiss();
|
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||||
|
if (actionCta.dismissOnAction !== false) onDismiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!notice.cta) return null;
|
if (!notice.cta) return null;
|
||||||
|
|
||||||
if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
|
if (notice.cta.kind === 'nav') {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={notice.cta.href}
|
href={notice.cta.href}
|
||||||
|
|||||||
@@ -1,26 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect } 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:
|
||||||
@@ -33,12 +17,9 @@ export function SystemNoticeHost() {
|
|||||||
|
|
||||||
if (!loaded) return null;
|
if (!loaded) return null;
|
||||||
|
|
||||||
// desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile.
|
const modals = notices.filter(n => n.display === 'modal');
|
||||||
const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices;
|
const banners = notices.filter(n => n.display === 'banner');
|
||||||
|
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, Coffee } from 'lucide-react';
|
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } 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,33 +36,6 @@ 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[];
|
||||||
}
|
}
|
||||||
@@ -73,14 +46,12 @@ 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;
|
||||||
@@ -90,7 +61,7 @@ interface ContentProps {
|
|||||||
onGoto: (i: number) => void;
|
onGoto: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, 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;
|
||||||
|
|
||||||
@@ -99,10 +70,6 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
|
|||||||
? ((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 */}
|
||||||
@@ -137,9 +104,17 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
|
|||||||
|
|
||||||
{/* 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-6 text-center">
|
<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="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' }} />
|
||||||
<h2 id={titleId} className="relative text-xl font-bold text-white leading-tight">{title}</h2>
|
<div className="relative flex items-center justify-center gap-3">
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -222,27 +197,24 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Highlights — compact pills */}
|
{/* Highlights */}
|
||||||
{notice.highlights && notice.highlights.length > 0 && (
|
{notice.highlights && notice.highlights.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-2 mb-4">
|
<ul className="mx-auto mb-4 space-y-2">
|
||||||
{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 (
|
||||||
<span
|
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
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={13} className="text-indigo-500 dark:text-indigo-400 shrink-0" />
|
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||||
: <span className="text-indigo-500 shrink-0">✓</span>
|
: <span className="text-blue-500 shrink-0">✓</span>
|
||||||
}
|
}
|
||||||
{t(h.labelKey)}
|
{t(h.labelKey)}
|
||||||
</span>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,37 +270,16 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA(s) + dismiss link */}
|
{/* CTA + dismiss link */}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{ctaLabel && isLastPage ? (
|
{ctaLabel && isLastPage ? (
|
||||||
<div className="flex w-full flex-col sm:flex-row gap-2.5">
|
<button
|
||||||
<button
|
id={`notice-cta-${notice.id}`}
|
||||||
id={`notice-cta-${notice.id}`}
|
onClick={onCTA}
|
||||||
onClick={onCTA}
|
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||||
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.cta?.kind === 'link'
|
{ctaLabel}
|
||||||
? 'bg-[#FFDD00] text-[#0D0C22] hover:brightness-95'
|
</button>
|
||||||
: '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}`}
|
||||||
@@ -338,6 +289,14 @@ function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, title
|
|||||||
{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>
|
||||||
@@ -551,22 +510,21 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
|||||||
notices.forEach(n => dismiss(n.id));
|
notices.forEach(n => dismiss(n.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCta(cta: SystemNoticeDTO['cta']) {
|
function handleCTA() {
|
||||||
if (!cta) { handleDismissAll(); return; }
|
if (!notice) return;
|
||||||
if (cta.kind === 'nav') {
|
if (!notice.cta) {
|
||||||
navigate(cta.href);
|
handleDismissAll();
|
||||||
if (notice?.dismissible !== false) handleDismissAll();
|
return;
|
||||||
} else if (cta.kind === 'link') {
|
}
|
||||||
// External link (e.g. Buy Me a Coffee / Ko-fi): open in a new tab and leave the
|
if (notice.cta.kind === 'nav') {
|
||||||
// notice open so the user can use the other button too.
|
navigate(notice.cta.href);
|
||||||
window.open(cta.href, '_blank', 'noopener,noreferrer');
|
if (notice.dismissible !== false) handleDismissAll();
|
||||||
} else {
|
} else {
|
||||||
runNoticeAction(cta.actionId, { navigate });
|
runNoticeAction(notice.cta.actionId, { navigate });
|
||||||
if (cta.dismissOnAction !== false) handleDismissAll();
|
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||||
|
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;
|
||||||
@@ -626,7 +584,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, handleSecondaryCTA, animatedDismissAll,
|
announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll,
|
||||||
handlePrev, handleNext, handleGoto,
|
handlePrev, handleNext, handleGoto,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -635,7 +593,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, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
|
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, 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(
|
||||||
@@ -648,14 +606,12 @@ 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,
|
||||||
|
|||||||
@@ -102,9 +102,7 @@ export function ToastContainer() {
|
|||||||
`}</style>
|
`}</style>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||||||
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
|
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||||
// blur) so error toasts paint on top and stay legible instead of blurred behind.
|
|
||||||
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
|
||||||
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
||||||
}}>
|
}}>
|
||||||
{toasts.map(toast => (
|
{toasts.map(toast => (
|
||||||
|
|||||||
@@ -1,33 +1,23 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||||
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
|
|
||||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||||
import { getDayBookendHotels } from '../utils/dayOrder'
|
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult, Accommodation } from '../types'
|
import type { RouteSegment, RouteResult } from '../types'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
||||||
|
|
||||||
const NO_ACCOMMODATIONS: Accommodation[] = []
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||||
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||||
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||||
*/
|
*/
|
||||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
|
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
|
||||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||||
const routeAbortRef = useRef<AbortController | null>(null)
|
const routeAbortRef = useRef<AbortController | null>(null)
|
||||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||||
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
|
|
||||||
// hotel) unless the user turned the setting off — same gate as the sidebar.
|
|
||||||
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
|
|
||||||
// Recompute when the user flips km↔mi so leg distances (formatted at compute time)
|
|
||||||
// refresh instead of showing stale cached text (#1300).
|
|
||||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit)
|
|
||||||
|
|
||||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
@@ -103,55 +93,10 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
}
|
}
|
||||||
if (currentRun.length >= 2) runs.push(currentRun)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
|
|
||||||
// Bookend the route with the day's accommodation: a hotel → first-stop run and
|
|
||||||
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
|
|
||||||
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
|
|
||||||
// transfer day) and already filters to accommodations that have coordinates.
|
|
||||||
const day = allDays.find(d => d.id === dayId)
|
|
||||||
const bookends = day && optimizeFromAccommodation !== false
|
|
||||||
? getDayBookendHotels(day, allDays, accommodations)
|
|
||||||
: null
|
|
||||||
const flatPts: { lat: number; lng: number }[] = []
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
|
|
||||||
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
|
|
||||||
}
|
|
||||||
const hotelPt = (a?: Accommodation) =>
|
|
||||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
|
|
||||||
// Only draw a hotel bookend when the leg is real. A hotel → first-stop leg holds
|
|
||||||
// if the first stop is a place, or if you actually slept in that hotel last night;
|
|
||||||
// on a day-1 arrival the morning hotel is just a check-in fallback and the first
|
|
||||||
// waypoint is the transport's departure point, so [hotel → departure] is dropped
|
|
||||||
// (#1321). Symmetrically, [last-stop → hotel] is dropped when you leave on a transport
|
|
||||||
// in the evening and don't sleep in that hotel tonight.
|
|
||||||
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
|
|
||||||
const firstStop = entries.find(contributes)
|
|
||||||
const lastStop = [...entries].reverse().find(contributes)
|
|
||||||
const drawMorning = firstStop?.kind === 'place' || !!bookends?.morningIsSleptHere
|
|
||||||
const drawEvening = lastStop?.kind === 'place' || !!bookends?.eveningIsOvernight
|
|
||||||
const runsWithHotel = withHotelBookends(
|
|
||||||
runs,
|
|
||||||
flatPts[0],
|
|
||||||
flatPts[flatPts.length - 1],
|
|
||||||
drawMorning ? hotelPt(bookends?.morning) : null,
|
|
||||||
drawEvening ? hotelPt(bookends?.evening) : null,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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]))
|
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
|
||||||
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
|
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||||
|
|
||||||
// Draw straight lines immediately for snappiness, then upgrade to the real
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||||
// OSRM road geometry.
|
// OSRM road geometry.
|
||||||
@@ -162,7 +107,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
try {
|
try {
|
||||||
const polylines: [number, number][][] = []
|
const polylines: [number, number][][] = []
|
||||||
const allLegs: RouteSegment[] = []
|
const allLegs: RouteSegment[] = []
|
||||||
for (const run of runsWithHotel) {
|
for (const run of runs) {
|
||||||
try {
|
try {
|
||||||
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
||||||
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
@@ -178,7 +123,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, distanceUnit])
|
}, [enabled, profile])
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -202,7 +147,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, distanceUnit])
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ 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
|
||||||
|
|||||||
@@ -35,19 +35,17 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
|||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GL hover popup — the name/category/address card on marker hover.
|
/* Mapbox 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;
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ 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, buildSettings } from '../../tests/helpers/factories';
|
import { buildUser, buildAdmin, buildTrip } 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(() => {
|
||||||
@@ -799,51 +798,10 @@ 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: '',
|
||||||
@@ -854,7 +812,6 @@ 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,
|
||||||
@@ -874,32 +831,4 @@ 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']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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'
|
||||||
|
|
||||||
@@ -85,7 +84,6 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const {
|
const {
|
||||||
demoMode, locale, t, navigate,
|
demoMode, locale, t, navigate,
|
||||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||||
loadError, retryLoad,
|
|
||||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||||
@@ -104,15 +102,6 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<MobileTopBar />
|
<MobileTopBar />
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<div className="page-main">
|
<div className="page-main">
|
||||||
{loadError && (
|
|
||||||
<div className="dash-error" role="alert">
|
|
||||||
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
|
|
||||||
<button className="dash-error-retry" onClick={retryLoad}>
|
|
||||||
<RefreshCw size={15} />
|
|
||||||
{t('dashboard.retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{spotlight && (
|
{spotlight && (
|
||||||
<BoardingPassHero
|
<BoardingPassHero
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
@@ -143,13 +132,6 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
|
|
||||||
<div className="trips-empty">
|
|
||||||
<h4>{t('dashboard.emptyTitle')}</h4>
|
|
||||||
<p>{t('dashboard.emptyText')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
|
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
|
||||||
{gridTrips.map(trip => (
|
{gridTrips.map(trip => (
|
||||||
<TripCard
|
<TripCard
|
||||||
@@ -359,27 +341,12 @@ 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 distance = convertDistance(distanceKm, distanceUnit)
|
const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm)
|
||||||
const distanceText = formatCompactDistance(distance)
|
const equatorTimes = (distanceKm / 40075).toFixed(2)
|
||||||
const equatorDistance = convertDistance(40075, distanceUnit)
|
|
||||||
const equatorTimes = (distance / equatorDistance).toFixed(2)
|
|
||||||
const distanceLabel = getDistanceUnitLabel(distanceUnit)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="atlas">
|
<section className="atlas">
|
||||||
@@ -417,7 +384,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">{distanceLabel}</span></div>
|
<div className="value mono">{distanceText} <span className="unit">{t('dashboard.atlas.kmUnit')}</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" />
|
||||||
@@ -491,12 +458,8 @@ 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 isLoaded = useSettingsStore(s => s.isLoaded)
|
const [from, setFrom] = useState(() => localStorage.getItem('trek_fx_from') || 'EUR')
|
||||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
const [to, setTo] = useState(() => localStorage.getItem('trek_fx_to') || 'USD')
|
||||||
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)
|
||||||
|
|
||||||
@@ -514,18 +477,7 @@ function CurrencyTool(): React.ReactElement {
|
|||||||
}, [from])
|
}, [from])
|
||||||
|
|
||||||
useEffect(() => { fetchRate() }, [fetchRate])
|
useEffect(() => { fetchRate() }, [fetchRate])
|
||||||
// One-time migration of the pre-3.1.3 localStorage values into the user's settings,
|
useEffect(() => { localStorage.setItem('trek_fx_from', from); localStorage.setItem('trek_fx_to', to) }, [from, to])
|
||||||
// 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 }))
|
||||||
@@ -580,12 +532,13 @@ 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 isLoaded = useSettingsStore(s => s.isLoaded)
|
const [zones, setZones] = useState<string[]>(() => {
|
||||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
try {
|
||||||
const stored = useSettingsStore(s => s.settings.dashboard_timezones)
|
const raw = localStorage.getItem('trek_dashboard_tz')
|
||||||
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
|
if (raw) return JSON.parse(raw)
|
||||||
const zones = stored ?? [home, ...DEFAULT_ZONES]
|
} catch { /* ignore malformed storage */ }
|
||||||
const setZones = (next: string[]) => { updateSetting('dashboard_timezones', next).catch(() => {}) }
|
return [home, ...DEFAULT_ZONES]
|
||||||
|
})
|
||||||
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.
|
||||||
@@ -594,18 +547,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// One-time migration of the pre-3.1.3 localStorage value into the user's settings,
|
useEffect(() => { localStorage.setItem('trek_dashboard_tz', JSON.stringify(zones)) }, [zones])
|
||||||
// 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
|
||||||
@@ -616,8 +558,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 && !zones.includes(tz)) setZones([...zones, tz]); setAdding(false) }
|
const addZone = (tz: string) => { if (tz) setZones(prev => prev.includes(tz) ? prev : [...prev, tz]); setAdding(false) }
|
||||||
const removeZone = (tz: string) => setZones(zones.filter(z => z !== tz))
|
const removeZone = (tz: string) => setZones(prev => prev.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) => {
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ 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, seedStore } from '../../tests/helpers/store';
|
import { resetAllStores } 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)
|
||||||
@@ -482,31 +480,4 @@ 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());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 || t('dayplan.dayN', { n: day.day_number })}</div>
|
<div className="text-[#111827]" style={{ fontSize: 14, fontWeight: 600 }}>{day.title || `Day ${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) => (
|
||||||
|
|||||||
@@ -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, type CompassMap } from '../components/Map/MapCompassPill'
|
import { MapCompassPill } 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'
|
||||||
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||||
selectedPlace, dayOrderMap, dayPlaces,
|
selectedPlace, dayOrderMap, dayPlaces,
|
||||||
@@ -211,7 +211,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
} = useTripPlanner()
|
} = useTripPlanner()
|
||||||
|
|
||||||
const poi = usePoiExplore()
|
const poi = usePoiExplore()
|
||||||
const [glMap, setGlMap] = useState<CompassMap | null>(null)
|
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | 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
|
||||||
@@ -465,7 +465,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onPlaceClick={handlePlaceClick}
|
onPlaceClick={handlePlaceClick}
|
||||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onEditPlace={(place) => openPlaceEditor(place)}
|
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||||
onCategoryFilterChange={setMapCategoryFilter}
|
onCategoryFilterChange={setMapCategoryFilter}
|
||||||
@@ -531,7 +531,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
|
onEdit={() => {
|
||||||
|
if (selectedAssignmentId) {
|
||||||
|
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||||
|
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||||
|
setEditingPlace(placeWithAssignmentTimes)
|
||||||
|
} else {
|
||||||
|
setEditingPlace(selectedPlace)
|
||||||
|
}
|
||||||
|
setEditingAssignmentId(selectedAssignmentId || null)
|
||||||
|
setShowPlaceForm(true)
|
||||||
|
}}
|
||||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
@@ -569,7 +579,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
|
onEdit={() => {
|
||||||
|
if (selectedAssignmentId) {
|
||||||
|
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||||
|
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||||
|
setEditingPlace(placeWithAssignmentTimes)
|
||||||
|
} else {
|
||||||
|
setEditingPlace(selectedPlace)
|
||||||
|
}
|
||||||
|
setEditingAssignmentId(selectedAssignmentId || null)
|
||||||
|
setShowPlaceForm(true)
|
||||||
|
setSelectedPlaceId(null)
|
||||||
|
}}
|
||||||
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
@@ -610,7 +631,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -696,7 +717,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
||||||
|
|||||||
@@ -229,24 +229,12 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
|
|
||||||
<div style={{ padding: '20px 24px' }}>
|
<div style={{ padding: '20px 24px' }}>
|
||||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{updateInfo?.is_docker === false ? (
|
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||||
<a
|
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||||
href="https://github.com/mauriceboe/TREK/wiki/Updating"
|
>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
|
|
||||||
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
|
||||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
|
||||||
>
|
|
||||||
{`docker pull mauriceboe/trek:latest
|
{`docker pull mauriceboe/trek:latest
|
||||||
docker stop trek && docker rm trek
|
docker stop trek && docker rm trek
|
||||||
docker run -d --name trek \\
|
docker run -d --name trek \\
|
||||||
@@ -255,8 +243,7 @@ docker run -d --name trek \\
|
|||||||
-v /opt/trek/uploads:/app/uploads \\
|
-v /opt/trek/uploads:/app/uploads \\
|
||||||
--restart unless-stopped \\
|
--restart unless-stopped \\
|
||||||
mauriceboe/trek:latest`}
|
mauriceboe/trek:latest`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||||
|
|||||||
@@ -134,12 +134,9 @@ export function useAtlas() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
||||||
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
|
// no third-party fetch from the browser).
|
||||||
// it gets a longer timeout than the global 8s default to survive slow links and
|
|
||||||
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
|
|
||||||
// with no countries (#1254).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
|
apiClient.get('/addons/atlas/countries/geo')
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const geo = res.data
|
const geo = res.data
|
||||||
// Dynamically build A2→A3 mapping from GeoJSON
|
// Dynamically build A2→A3 mapping from GeoJSON
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export function useDashboard() {
|
|||||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||||
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
||||||
const [loadError, setLoadError] = useState<boolean>(false)
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<TravelStats | null>(null)
|
const [stats, setStats] = useState<TravelStats | null>(null)
|
||||||
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
|
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
|
||||||
@@ -43,7 +42,7 @@ export function useDashboard() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
|
const { demoMode } = useAuthStore()
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -75,22 +74,13 @@ export function useDashboard() {
|
|||||||
const { trips, archivedTrips } = await tripRepo.list()
|
const { trips, archivedTrips } = await tripRepo.list()
|
||||||
setTrips(sortTrips(trips))
|
setTrips(sortTrips(trips))
|
||||||
setArchivedTrips(sortTrips(archivedTrips))
|
setArchivedTrips(sortTrips(archivedTrips))
|
||||||
setLoadError(false)
|
|
||||||
} catch {
|
} catch {
|
||||||
setLoadError(true)
|
|
||||||
toast.error(t('dashboard.toast.loadError'))
|
toast.error(t('dashboard.toast.loadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-run both the trip fetch and the auth check so a recovered backend clears
|
|
||||||
// the error banner (loadUser resets authCheckFailed on success). #1283
|
|
||||||
const retryLoad = () => {
|
|
||||||
loadUser({ silent: true })
|
|
||||||
loadTrips()
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
||||||
|| trips.find(t => t.start_date && t.start_date >= today)
|
|| trips.find(t => t.start_date && t.start_date >= today)
|
||||||
@@ -187,7 +177,6 @@ export function useDashboard() {
|
|||||||
demoMode, locale, t, navigate,
|
demoMode, locale, t, navigate,
|
||||||
// data + derived
|
// data + derived
|
||||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||||
loadError: loadError || authCheckFailed, retryLoad,
|
|
||||||
// ui state
|
// ui state
|
||||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
|
||||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
|
|
||||||
|
|
||||||
describe('resolvePoolAssignmentId', () => {
|
|
||||||
it('returns the lone assignment id when the place is assigned to exactly one day', () => {
|
|
||||||
const place = buildPlace({ id: 7 })
|
|
||||||
const assignment = buildAssignment({ id: 42, day_id: 3, place })
|
|
||||||
const assignments = { 3: [assignment], 4: [buildAssignment({ id: 99, day_id: 4 })] }
|
|
||||||
expect(resolvePoolAssignmentId(assignments, 7)).toBe(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when the place is not assigned to any day', () => {
|
|
||||||
const assignments = { 3: [buildAssignment({ id: 99, day_id: 3 })] }
|
|
||||||
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when the place is assigned to multiple days (ambiguous time)', () => {
|
|
||||||
const assignments = {
|
|
||||||
3: [buildAssignment({ id: 1, day_id: 3, place: buildPlace({ id: 7 }) })],
|
|
||||||
4: [buildAssignment({ id: 2, day_id: 4, place: buildPlace({ id: 7 }) })],
|
|
||||||
}
|
|
||||||
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Trip planner pure helpers — React/IO-free logic shared by the data hook
|
|
||||||
* (useTripPlanner) and kept here so it can be unit-tested in isolation. Part of
|
|
||||||
* the FE "page = wiring container + data hook" convention (see PATTERN.md).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Assignment } from '../../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the day-assignment to use when a place is edited from the Places pool,
|
|
||||||
* where no day is in context. Times live per day-assignment (#1247), so we can
|
|
||||||
* only hydrate/persist a place's time when it is assigned to exactly one day.
|
|
||||||
* Returns that assignment's id, or null when the place has 0 or 2+ assignments
|
|
||||||
* (ambiguous — the modal then hides the time fields).
|
|
||||||
*/
|
|
||||||
export function resolvePoolAssignmentId(
|
|
||||||
assignments: Record<string | number, Assignment[]>,
|
|
||||||
placeId: number,
|
|
||||||
): number | null {
|
|
||||||
const matches = Object.values(assignments)
|
|
||||||
.flat()
|
|
||||||
.filter((a) => a.place?.id === placeId)
|
|
||||||
return matches.length === 1 ? matches[0].id : null
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
|||||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
||||||
@@ -289,7 +288,7 @@ export function useTripPlanner() {
|
|||||||
})
|
})
|
||||||
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
||||||
|
|
||||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
||||||
const changed = dayId !== selectedDayId
|
const changed = dayId !== selectedDayId
|
||||||
@@ -424,16 +423,6 @@ export function useTripPlanner() {
|
|||||||
}
|
}
|
||||||
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
||||||
|
|
||||||
// Open the place editor from any entry point (Places pool, inspector, map).
|
|
||||||
// Times live per day-assignment, so when no day is in context resolve the
|
|
||||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
|
||||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
|
||||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
|
||||||
setEditingPlace(place)
|
|
||||||
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
|
|
||||||
setShowPlaceForm(true)
|
|
||||||
}, [assignments])
|
|
||||||
|
|
||||||
const handleDeletePlace = useCallback((placeId) => {
|
const handleDeletePlace = useCallback((placeId) => {
|
||||||
setDeletePlaceId(placeId)
|
setDeletePlaceId(placeId)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -701,7 +690,7 @@ export function useTripPlanner() {
|
|||||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||||
selectedPlace, dayOrderMap, dayPlaces,
|
selectedPlace, dayOrderMap, dayPlaces,
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ interface AuthState {
|
|||||||
user: User | null
|
user: User | null
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
/** The auth check (loadUser) failed for a non-401 reason while we were online —
|
|
||||||
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
|
|
||||||
* outage doesn't render as a blank, error-free page that looks like lost data.
|
|
||||||
* Transient, never persisted. #1283 */
|
|
||||||
authCheckFailed: boolean
|
|
||||||
error: string | null
|
error: string | null
|
||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
devMode: boolean
|
devMode: boolean
|
||||||
@@ -91,7 +86,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
authCheckFailed: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||||
devMode: false,
|
devMode: false,
|
||||||
@@ -206,7 +200,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
authCheckFailed: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -222,33 +215,22 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: data.user,
|
user: data.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
authCheckFailed: false,
|
|
||||||
})
|
})
|
||||||
await onAuthSuccess(data.user.id)
|
await onAuthSuccess(data.user.id)
|
||||||
connect()
|
connect()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (seq !== authSequence) return // stale response — ignore
|
if (seq !== authSequence) return // stale response — ignore
|
||||||
const status = err && typeof err === 'object' && 'response' in err
|
// Only clear auth state on 401 (invalid/expired token), not on network errors
|
||||||
? (err as { response?: { status?: number } }).response?.status
|
const isAuthError = err && typeof err === 'object' && 'response' in err &&
|
||||||
: undefined
|
(err as { response?: { status?: number } }).response?.status === 401
|
||||||
if (status === 401) {
|
if (isAuthError) {
|
||||||
// Invalid/expired token — clear auth so the guard redirects to login.
|
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
authCheckFailed: false,
|
|
||||||
})
|
})
|
||||||
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
||||||
// Genuinely offline — keep the persisted session so the PWA serves cached
|
|
||||||
// data without a scary error. This is the offline-first happy path.
|
|
||||||
set({ isLoading: false })
|
|
||||||
} else {
|
} else {
|
||||||
// Server erroring (5xx) or unreachable while we're online: keep the session
|
set({ isLoading: false })
|
||||||
// (don't eject the user over a transient outage), but flag it so the UI can
|
|
||||||
// say "couldn't reach the server" instead of showing a blank, error-free
|
|
||||||
// page that looks like the user's trips were lost. #1283
|
|
||||||
set({ isLoading: false, authCheckFailed: true })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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,
|
||||||
@@ -38,13 +37,8 @@ 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,
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,7 @@
|
|||||||
opacity: .88; margin-bottom: 16px; font-weight: 500;
|
opacity: .88; margin-bottom: 16px; font-weight: 500;
|
||||||
}
|
}
|
||||||
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
|
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
|
||||||
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
|
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
|
||||||
|
|
||||||
/* ----------------- boarding pass ----------------- */
|
/* ----------------- boarding pass ----------------- */
|
||||||
.trek-dash .hero-pass {
|
.trek-dash .hero-pass {
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
|
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
|
||||||
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
|
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
|
||||||
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
||||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
|
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
|
||||||
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
||||||
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
|
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
|
||||||
.trek-dash .trip-body { padding: 18px 20px 20px; }
|
.trek-dash .trip-body { padding: 18px 20px 20px; }
|
||||||
@@ -456,33 +456,6 @@
|
|||||||
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
|
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
|
||||||
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
|
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
|
||||||
|
|
||||||
/* Error banner — shown when the trip list or the auth check couldn't reach the
|
|
||||||
server, so a backend/IdP outage no longer looks like an empty (lost-data)
|
|
||||||
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
|
|
||||||
.trek-dash .dash-error {
|
|
||||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
|
||||||
padding: 14px 18px; margin-bottom: 22px;
|
|
||||||
background: oklch(0.74 0.14 75 / 0.13);
|
|
||||||
border: 1px solid oklch(0.74 0.14 75 / 0.45);
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
box-shadow: var(--sh-sm);
|
|
||||||
}
|
|
||||||
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
|
|
||||||
.trek-dash .dash-error-retry {
|
|
||||||
display: inline-flex; align-items: center; gap: 7px;
|
|
||||||
padding: 8px 14px; border: none; border-radius: var(--r-xs);
|
|
||||||
background: var(--ink); color: var(--surface);
|
|
||||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
|
||||||
transition: opacity .15s ease;
|
|
||||||
}
|
|
||||||
.trek-dash .dash-error-retry:hover { opacity: .88; }
|
|
||||||
|
|
||||||
/* Empty state — a genuine "you have no trips yet" message, visually distinct
|
|
||||||
from the error banner above so an outage and a real empty list never look alike. */
|
|
||||||
.trek-dash .trips-empty { margin-bottom: 18px; }
|
|
||||||
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
|
|
||||||
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
|
|
||||||
|
|
||||||
/* ----------------- tools sidebar ----------------- */
|
/* ----------------- tools sidebar ----------------- */
|
||||||
.trek-dash .tool {
|
.trek-dash .tool {
|
||||||
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
|
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
|
||||||
|
|||||||
+1
-9
@@ -100,8 +100,6 @@ 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
|
||||||
@@ -111,23 +109,17 @@ 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' | 'maplibre-gl'
|
map_provider?: 'leaflet' | 'mapbox-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 {
|
||||||
|
|||||||
@@ -117,40 +117,4 @@ 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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const getDayBookendHotels = (
|
|||||||
day: Day,
|
day: Day,
|
||||||
days: Day[],
|
days: Day[],
|
||||||
accommodations: Accommodation[],
|
accommodations: Accommodation[],
|
||||||
): { morning?: Accommodation; evening?: Accommodation; morningIsSleptHere?: boolean; eveningIsOvernight?: boolean } => {
|
): { morning?: Accommodation; evening?: Accommodation } => {
|
||||||
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,13 +30,6 @@ 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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { DistanceUnit } from '../types'
|
|
||||||
|
|
||||||
const KM_TO_MI = 0.621371
|
|
||||||
const M_TO_FT = 3.28084
|
|
||||||
|
|
||||||
export function getDistanceUnitLabel(unit: DistanceUnit): 'km' | 'mi' {
|
|
||||||
return unit === 'imperial' ? 'mi' : 'km'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formats an elevation in metres as feet for imperial, so it doesn't mix with mi distances. */
|
|
||||||
export function formatElevation(meters: number, unit: DistanceUnit): string {
|
|
||||||
const safe = Number.isFinite(meters) ? meters : 0
|
|
||||||
return unit === 'imperial' ? `${Math.round(safe * M_TO_FT)} ft` : `${Math.round(safe)} m`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertDistance(km: number, unit: DistanceUnit): number {
|
|
||||||
const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0
|
|
||||||
return unit === 'imperial' ? safeKm * KM_TO_MI : safeKm
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDistance(km: number, unit: DistanceUnit): string {
|
|
||||||
const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0
|
|
||||||
// Metric keeps a metres reading below 1 km (e.g. "300 m"), matching the route
|
|
||||||
// connectors; imperial has no sub-mile unit, so short hops just show "0.x mi".
|
|
||||||
if (unit === 'metric' && safeKm < 1) {
|
|
||||||
return `${Math.round(safeKm * 1000)} m`
|
|
||||||
}
|
|
||||||
const value = convertDistance(safeKm, unit)
|
|
||||||
const label = getDistanceUnitLabel(unit)
|
|
||||||
const rounded = Math.round(value * 10) / 10
|
|
||||||
// String() keeps a '.' decimal regardless of locale, matching the rest of the app
|
|
||||||
// (toFixed elsewhere) and avoiding "1,5 km" in non-English environments.
|
|
||||||
const text = value > 0 && rounded === 0 ? '<0.1' : String(rounded)
|
|
||||||
return `${text} ${label}`
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,13 @@ import { buildAssignment, buildPlace } from '../../helpers/factories';
|
|||||||
import type { TripStoreState } from '../../../src/store/tripStore';
|
import type { TripStoreState } from '../../../src/store/tripStore';
|
||||||
import type { RouteSegment } from '../../../src/types';
|
import type { RouteSegment } from '../../../src/types';
|
||||||
|
|
||||||
vi.mock('../../../src/components/Map/RouteCalculator', async (importActual) => {
|
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
||||||
const actual = await importActual<typeof import('../../../src/components/Map/RouteCalculator')>();
|
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||||
return {
|
calculateRouteWithLegs: vi.fn(),
|
||||||
...actual,
|
calculateRoute: vi.fn(),
|
||||||
calculateRouteWithLegs: vi.fn(),
|
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
||||||
calculateRoute: vi.fn(),
|
generateGoogleMapsUrl: vi.fn(),
|
||||||
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
}));
|
||||||
generateGoogleMapsUrl: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
|
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
|
||||||
|
|
||||||
@@ -251,126 +248,6 @@ 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(() =>
|
||||||
|
|||||||
@@ -91,13 +91,12 @@ 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(21)
|
expect(SUPPORTED_LANGUAGES).toHaveLength(20)
|
||||||
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: 'العربية' }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,18 +63,6 @@ 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
|
||||||
|
|||||||
Generated
+1306
-1453
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/root",
|
"name": "@trek/root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.1.3",
|
"version": "3.1.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"client",
|
"client",
|
||||||
"server",
|
"server",
|
||||||
@@ -25,19 +25,17 @@
|
|||||||
"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": {
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6"
|
||||||
"multer": "^2.2.0"
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.1",
|
"@rollup/rollup-linux-x64-musl": "4.62.0",
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.1",
|
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.62.0",
|
"@rollup/rollup-linux-arm64-musl": "4.62.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.62.0"
|
"@img/sharp-linuxmusl-x64": "0.35.1",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.35.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-9
@@ -40,13 +40,8 @@ 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.
|
# Initial admin account — only used on first boot when no users exist yet.
|
||||||
# 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)
|
# 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.
|
||||||
# Initial admin account — ONLY applied on the first boot, when the database has no
|
|
||||||
# users yet. Adding these later has no effect (the server logs a reminder if you do);
|
|
||||||
# to change an existing password sign in and use Settings, or reset the admin account.
|
|
||||||
# Both must be set together. If either is omitted, a random password is generated and
|
|
||||||
# printed to the server log under "First Run: Admin Account Created" — watch for it.
|
|
||||||
# ADMIN_EMAIL=admin@trek.local
|
# ADMIN_EMAIL=admin@trek.local
|
||||||
# ADMIN_PASSWORD=change-me-before-first-boot
|
# ADMIN_PASSWORD=changeme
|
||||||
|
|||||||
+5
-7
@@ -1,13 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/server",
|
"name": "@trek/server",
|
||||||
"version": "3.1.3",
|
"version": "3.1.0",
|
||||||
"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",
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "node scripts/dev.mjs",
|
||||||
"build": "node scripts/build.mjs",
|
"build": "node scripts/build.mjs",
|
||||||
"start:prod": "node --require tsconfig-paths/register dist/index.js",
|
"start:prod": "node --require tsconfig-paths/register dist/index.js",
|
||||||
"reset-admin": "node reset-admin.js",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"compression": "^1.8.0",
|
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
@@ -40,8 +38,9 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jimp": "^1.6.1",
|
"jimp": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^8.0.5",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -61,7 +60,7 @@
|
|||||||
"@hono/node-server": "^1.19.13",
|
"@hono/node-server": "^1.19.13",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"ip-address": "^10.1.1",
|
"ip-address": "^10.1.1",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.1.1",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
"qs": "^6.15.2",
|
"qs": "^6.15.2",
|
||||||
"file-type": "^21.3.4"
|
"file-type": "^21.3.4"
|
||||||
@@ -74,7 +73,6 @@
|
|||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/compression": "^1.8.0",
|
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
@@ -82,7 +80,7 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
|
|||||||
+9
-38
@@ -1,50 +1,21 @@
|
|||||||
/**
|
|
||||||
* Admin recovery — reset (or create) an admin account when you are locked out.
|
|
||||||
*
|
|
||||||
* Usage inside the container:
|
|
||||||
* docker exec -it trek node server/reset-admin.js
|
|
||||||
* docker exec -it -e RESET_ADMIN_EMAIL=me@example.com -e RESET_ADMIN_PASSWORD=secret trek node server/reset-admin.js
|
|
||||||
*
|
|
||||||
* Defaults to admin@trek.local with a generated password (printed below). The
|
|
||||||
* account is flagged must_change_password, so you are prompted to set a new one
|
|
||||||
* on first login. Honours TREK_DB_FILE the same way the server does.
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
// Kept in sync with the seeder/authService cost factor.
|
const dbPath = path.join(__dirname, 'data/travel.db');
|
||||||
const BCRYPT_COST = 12;
|
|
||||||
|
|
||||||
const email = process.env.RESET_ADMIN_EMAIL || 'admin@trek.local';
|
|
||||||
const password = process.env.RESET_ADMIN_PASSWORD || crypto.randomBytes(12).toString('base64url');
|
|
||||||
const generated = !process.env.RESET_ADMIN_PASSWORD;
|
|
||||||
|
|
||||||
const dbPath = process.env.TREK_DB_FILE || path.join(__dirname, 'data/travel.db');
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(password, BCRYPT_COST);
|
const hash = bcrypt.hashSync('admin123', 10);
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('UPDATE users SET password_hash = ?, role = ?, must_change_password = 1 WHERE email = ?')
|
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?')
|
||||||
.run(hash, 'admin', email);
|
.run(hash, 'admin', 'admin@admin.com');
|
||||||
console.log(`\n✓ Admin password reset: ${email}`);
|
console.log('✓ Admin-Passwort zurückgesetzt: admin@admin.com / admin123');
|
||||||
} else {
|
} else {
|
||||||
// 'admin' is usually taken by the first-run seed — pick the first free username
|
db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
|
||||||
// so the insert can't trip the UNIQUE(username) constraint.
|
.run('admin', 'admin@admin.com', hash, 'admin');
|
||||||
let username = 'admin';
|
console.log('✓ Admin-User erstellt: admin@admin.com / admin123');
|
||||||
let n = 1;
|
|
||||||
while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) {
|
|
||||||
username = `admin${n++}`;
|
|
||||||
}
|
|
||||||
db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)')
|
|
||||||
.run(username, email, hash, 'admin');
|
|
||||||
console.log(`\n✓ Admin account created: ${email} (username: ${username})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generated) console.log(` Password: ${password}`);
|
|
||||||
console.log(' You will be asked to change the password on first login.\n');
|
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
+36
-56
@@ -1,11 +1,39 @@
|
|||||||
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
|
|
||||||
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
|
||||||
|
|
||||||
const dataDir = path.resolve(__dirname, '../data');
|
const dataDir = path.resolve(__dirname, '../data');
|
||||||
|
|
||||||
|
// JWT_SECRET is always managed by the server — auto-generated on first start and
|
||||||
|
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
|
||||||
|
// via environment variable (env var would override a rotation on next restart).
|
||||||
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
|
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
|
||||||
|
let _jwtSecret: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
|
||||||
|
} catch {
|
||||||
|
_jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
|
||||||
|
console.log('Generated and saved JWT secret to', jwtSecretFile);
|
||||||
|
} catch (writeErr: unknown) {
|
||||||
|
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
|
||||||
|
console.warn('Sessions will reset on server restart.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
|
||||||
|
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
|
||||||
|
export let JWT_SECRET = _jwtSecret;
|
||||||
|
|
||||||
|
// Called by the admin rotate-jwt-secret endpoint to update the in-process
|
||||||
|
// binding that all middleware and route files reference.
|
||||||
|
export function updateJwtSecret(newSecret: string): void {
|
||||||
|
JWT_SECRET = newSecret;
|
||||||
|
}
|
||||||
|
|
||||||
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
|
// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
|
||||||
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
|
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
|
||||||
@@ -65,55 +93,18 @@ if (_encryptionKey) {
|
|||||||
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
|
fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
|
||||||
console.log('Encryption key persisted to', encKeyFile);
|
console.log('Encryption key persisted to', encKeyFile);
|
||||||
} catch (writeErr: unknown) {
|
} catch (writeErr: unknown) {
|
||||||
console.warn(
|
console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
|
||||||
'WARNING: Could not persist encryption key to disk:',
|
|
||||||
writeErr instanceof Error ? writeErr.message : writeErr,
|
|
||||||
);
|
|
||||||
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
|
console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ENCRYPTION_KEY = _encryptionKey;
|
export const ENCRYPTION_KEY = _encryptionKey;
|
||||||
|
|
||||||
// JWT_SECRET is always managed by the server — auto-generated on first start and
|
|
||||||
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
|
|
||||||
// via environment variable (env var would override a rotation on next restart).
|
|
||||||
let _jwtSecret: string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
|
|
||||||
} catch {
|
|
||||||
_jwtSecret = crypto.randomBytes(32).toString('hex');
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
|
|
||||||
console.log('Generated and saved JWT secret to', jwtSecretFile);
|
|
||||||
} catch (writeErr: unknown) {
|
|
||||||
console.warn(
|
|
||||||
'WARNING: Could not persist JWT secret to disk:',
|
|
||||||
writeErr instanceof Error ? writeErr.message : writeErr,
|
|
||||||
);
|
|
||||||
console.warn('Sessions will reset on server restart.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
|
|
||||||
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
|
|
||||||
export let JWT_SECRET = _jwtSecret;
|
|
||||||
|
|
||||||
// Called by the admin rotate-jwt-secret endpoint to update the in-process
|
|
||||||
// binding that all middleware and route files reference.
|
|
||||||
export function updateJwtSecret(newSecret: string): void {
|
|
||||||
JWT_SECRET = newSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
|
// DEFAULT_LANGUAGE sets the language shown on the login page before the user
|
||||||
// selects one. Only applies when the user has no saved language preference.
|
// selects one. Only applies when the user has no saved language preference.
|
||||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
||||||
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
||||||
console.warn(
|
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||||
`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
||||||
|
|
||||||
@@ -125,13 +116,7 @@ export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ?
|
|||||||
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
||||||
const DEFAULT_SESSION_DURATION = '24h';
|
const DEFAULT_SESSION_DURATION = '24h';
|
||||||
const DURATION_UNITS_MS: Record<string, number> = {
|
const DURATION_UNITS_MS: Record<string, number> = {
|
||||||
ms: 1,
|
ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
|
||||||
s: 1000,
|
|
||||||
m: 60_000,
|
|
||||||
h: 3_600_000,
|
|
||||||
d: 86_400_000,
|
|
||||||
w: 604_800_000,
|
|
||||||
y: 31_557_600_000,
|
|
||||||
};
|
};
|
||||||
function parseDurationMs(value: string): number | null {
|
function parseDurationMs(value: string): number | null {
|
||||||
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
||||||
@@ -143,9 +128,7 @@ function parseDurationMs(value: string): number | null {
|
|||||||
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
||||||
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
||||||
if (parsedSessionMs == null) {
|
if (parsedSessionMs == null) {
|
||||||
console.warn(
|
console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
|
||||||
`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
/** Human-readable session length actually in effect (for logs/diagnostics). */
|
/** Human-readable session length actually in effect (for logs/diagnostics). */
|
||||||
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
||||||
@@ -163,13 +146,10 @@ const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
|
|||||||
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
||||||
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
||||||
if (parsedRememberMs == null) {
|
if (parsedRememberMs == null) {
|
||||||
console.warn(
|
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
|
||||||
`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
|
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
|
||||||
export const SESSION_DURATION_REMEMBER =
|
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||||
parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
|
||||||
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
||||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||||
|
|||||||
@@ -3054,23 +3054,6 @@ 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) {
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ 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',
|
||||||
|
|||||||
+7
-23
@@ -15,21 +15,8 @@ function isOidcOnlyConfigured(): boolean {
|
|||||||
|
|
||||||
function seedAdminAccount(db: Database.Database): void {
|
function seedAdminAccount(db: Database.Database): void {
|
||||||
try {
|
try {
|
||||||
const env_admin_email = process.env.ADMIN_EMAIL;
|
|
||||||
const env_admin_pw = process.env.ADMIN_PASSWORD;
|
|
||||||
const adminEnvProvided = !!(env_admin_email || env_admin_pw);
|
|
||||||
|
|
||||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||||
if (userCount > 0) {
|
if (userCount > 0) return;
|
||||||
// ADMIN_EMAIL/ADMIN_PASSWORD only take effect on the first run (empty database). Once a
|
|
||||||
// user exists they are silently ignored — a common trip-up: people add the vars after the
|
|
||||||
// fact, restart, nothing changes, and there is no hint why. Say so instead of staying silent.
|
|
||||||
if (adminEnvProvided) {
|
|
||||||
console.warn('[admin] ADMIN_EMAIL/ADMIN_PASSWORD are set, but users already exist — these only apply on first run (empty database) and are being ignored.');
|
|
||||||
console.warn('[admin] Change an existing password from Settings after signing in, reset the admin (see the Troubleshooting wiki), or start with an empty data volume to re-run setup.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
||||||
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
||||||
@@ -48,18 +35,15 @@ function seedAdminAccount(db: Database.Database): void {
|
|||||||
|
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
let password: string;
|
const env_admin_email = process.env.ADMIN_EMAIL;
|
||||||
let email: string;
|
const env_admin_pw = process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
let password;
|
||||||
|
let email;
|
||||||
if (env_admin_email && env_admin_pw) {
|
if (env_admin_email && env_admin_pw) {
|
||||||
password = env_admin_pw;
|
password = env_admin_pw;
|
||||||
email = env_admin_email;
|
email = env_admin_email;
|
||||||
} else {
|
} else {
|
||||||
// A partial config (only one of the two) is an easy mistake: neither value is used and a
|
|
||||||
// generated password is created instead. Flag it so the chosen credentials silently not
|
|
||||||
// working isn't a surprise.
|
|
||||||
if (adminEnvProvided) {
|
|
||||||
console.warn('[admin] Only one of ADMIN_EMAIL/ADMIN_PASSWORD is set — both are required for a custom admin. Falling back to admin@trek.local with a generated password (shown below).');
|
|
||||||
}
|
|
||||||
password = crypto.randomBytes(12).toString('base64url');
|
password = crypto.randomBytes(12).toString('base64url');
|
||||||
email = 'admin@trek.local';
|
email = 'admin@trek.local';
|
||||||
}
|
}
|
||||||
@@ -170,4 +154,4 @@ function runSeeds(db: Database.Database): void {
|
|||||||
seedAddons(db);
|
seedAddons(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { runSeeds, seedAdminAccount };
|
export { runSeeds };
|
||||||
|
|||||||
@@ -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\`, \`google_place_id\`, and/or \`google_ftid\` in the result.
|
1. \`search_place\` — find the real-world POI; note the \`osm_id\` and/or \`google_place_id\` 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\`.
|
||||||
|
|
||||||
|
|||||||
@@ -66,17 +66,6 @@ export function hasTripPermission(action: string, tripId: number | string, userI
|
|||||||
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
|
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the user has the global admin role (mirrors REST `user.role === 'admin'` gates). */
|
|
||||||
export function isAdminUser(userId: number): boolean {
|
|
||||||
const userRow = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
|
||||||
return userRow?.role === 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Error response for admin-only tools, reproducing the REST `{ error: 'Admin access required' }` string. */
|
|
||||||
export function adminRequired() {
|
|
||||||
return { content: [{ type: 'text' as const, text: 'Admin access required' }], isError: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ok(data: unknown) {
|
export function ok(data: unknown) {
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,11 +136,7 @@ export function registerAtlasTools(server: McpServer, userId: number, scopes: st
|
|||||||
async ({ regionCode, regionName, countryCode }) => {
|
async ({ regionCode, regionName, countryCode }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
markRegionVisited(userId, regionCode, regionName, countryCode);
|
markRegionVisited(userId, regionCode, regionName, countryCode);
|
||||||
const row = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
|
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
|
||||||
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
|
|
||||||
const region = row
|
|
||||||
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
|
|
||||||
: undefined;
|
|
||||||
return ok({ region });
|
return ok({ region });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+25
-160
@@ -5,42 +5,18 @@ import { isDemoUser } from '../../services/authService';
|
|||||||
import {
|
import {
|
||||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||||
updateMembers as updateBudgetMembers,
|
updateMembers as updateBudgetMembers,
|
||||||
toggleMemberPaid, getBudgetItem,
|
toggleMemberPaid,
|
||||||
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
|
|
||||||
} from '../../services/budgetService';
|
} from '../../services/budgetService';
|
||||||
import { getRates } from '../../services/exchangeRateService';
|
|
||||||
import { getTripOwner, listMembers } from '../../services/tripService';
|
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY,
|
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||||
} from './_shared';
|
} from './_shared';
|
||||||
import { canRead, canWrite } from '../scopes';
|
import { canWrite } from '../scopes';
|
||||||
|
|
||||||
/** Reusable Zod shape for the per-payer amounts on a budget item. */
|
|
||||||
const payersSchema = z.array(z.object({
|
|
||||||
user_id: z.number().int().positive(),
|
|
||||||
amount: z.number().nonnegative(),
|
|
||||||
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
|
|
||||||
import { isAddonEnabled } from '../../services/adminService';
|
import { isAddonEnabled } from '../../services/adminService';
|
||||||
import { ADDON_IDS } from '../../addons';
|
import { ADDON_IDS } from '../../addons';
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the equal-split participants for a new budget item. When member_ids is
|
|
||||||
* omitted, default to the whole trip (owner + all members), deduped — reproducing
|
|
||||||
* the client's own create flow (CostsPanel seeds participants from all members).
|
|
||||||
* An explicit empty array means "planning-only, no split" and is passed through.
|
|
||||||
*/
|
|
||||||
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
|
|
||||||
if (member_ids !== undefined) return member_ids;
|
|
||||||
const owner = getTripOwner(tripId);
|
|
||||||
if (!owner) return undefined;
|
|
||||||
const { members } = listMembers(tripId, owner.user_id);
|
|
||||||
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||||
const R = canRead(scopes, 'budget');
|
|
||||||
const W = canWrite(scopes, 'budget');
|
const W = canWrite(scopes, 'budget');
|
||||||
|
|
||||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||||
@@ -49,26 +25,21 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_budget_item',
|
'create_budget_item',
|
||||||
{
|
{
|
||||||
description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split). Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid — resolve user IDs with list_trip_members — rather than guessing.',
|
description: 'Add a budget/expense item to a trip.',
|
||||||
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),
|
||||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||||
total_price: z.number().nonnegative(),
|
total_price: z.number().nonnegative(),
|
||||||
currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'),
|
|
||||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense. Omit to split across all trip members (owner + members); pass [] for no split.'),
|
|
||||||
payers: payersSchema.optional().describe('Who paid how much, in the expense currency. When given, total_price is derived from the sum. Ask the user; do not guess.'),
|
|
||||||
expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD'),
|
|
||||||
note: z.string().max(500).optional(),
|
note: z.string().max(500).optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, category, total_price, currency, member_ids, payers, expense_date, note }) => {
|
async ({ tripId, name, category, total_price, note }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
const members = resolveMemberIds(tripId, member_ids);
|
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||||
const item = createBudgetItem(tripId, { category, name, total_price, currency, member_ids: members, payers, expense_date, note });
|
|
||||||
safeBroadcast(tripId, 'budget:created', { item });
|
safeBroadcast(tripId, 'budget:created', { item });
|
||||||
return ok({ item });
|
return ok({ item });
|
||||||
}
|
}
|
||||||
@@ -100,26 +71,24 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'update_budget_item',
|
'update_budget_item',
|
||||||
{
|
{
|
||||||
description: 'Update an existing budget/expense item in a trip. You can also re-split it via member_ids and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members.',
|
description: 'Update an existing budget/expense item in a trip.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
itemId: z.number().int().positive(),
|
itemId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200).optional(),
|
||||||
category: z.string().max(100).optional(),
|
category: z.string().max(100).optional(),
|
||||||
total_price: z.number().nonnegative().optional(),
|
total_price: z.number().nonnegative().optional(),
|
||||||
member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense; replaces the current split. Omit to leave unchanged, pass [] for no split.'),
|
|
||||||
payers: payersSchema.optional().describe('Replaces who paid how much, in the expense currency. Omit to leave unchanged. Ask the user; do not guess.'),
|
|
||||||
persons: z.number().int().positive().nullable().optional(),
|
persons: z.number().int().positive().nullable().optional(),
|
||||||
days: z.number().int().positive().nullable().optional(),
|
days: z.number().int().positive().nullable().optional(),
|
||||||
note: z.string().max(500).nullable().optional(),
|
note: z.string().max(500).nullable().optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||||
},
|
},
|
||||||
async ({ tripId, itemId, name, category, total_price, member_ids, payers, persons, days, note }) => {
|
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, member_ids, payers, persons, days, note });
|
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
|
||||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||||
safeBroadcast(tripId, 'budget:updated', { item });
|
safeBroadcast(tripId, 'budget:updated', { item });
|
||||||
return ok({ item });
|
return ok({ item });
|
||||||
@@ -131,14 +100,14 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_budget_item_with_members',
|
'create_budget_item_with_members',
|
||||||
{
|
{
|
||||||
description: 'Create a budget/expense item and set the trip members splitting it in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist — if it already exists, use set_budget_item_members directly.',
|
description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
|
||||||
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),
|
||||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||||
total_price: z.number().nonnegative(),
|
total_price: z.number().nonnegative(),
|
||||||
note: z.string().max(500).optional(),
|
note: z.string().max(500).optional(),
|
||||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'),
|
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
@@ -146,16 +115,19 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
const hasMembers = userIds && userIds.length > 0;
|
||||||
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
|
|
||||||
try {
|
try {
|
||||||
const item = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
const created = createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
|
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||||
return getBudgetItem(created.id, tripId)!;
|
if (hasMembers) {
|
||||||
})();
|
return updateBudgetMembers(item.id, tripId, userIds!);
|
||||||
safeBroadcast(tripId, 'budget:created', { item });
|
}
|
||||||
if (members && members.length > 0) safeBroadcast(tripId, 'budget:members-updated', { item });
|
return { item };
|
||||||
return ok({ item });
|
});
|
||||||
|
const result = run();
|
||||||
|
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
|
||||||
|
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
|
||||||
|
return ok({ item: result });
|
||||||
} catch {
|
} catch {
|
||||||
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
||||||
}
|
}
|
||||||
@@ -165,7 +137,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'set_budget_item_members',
|
'set_budget_item_members',
|
||||||
{
|
{
|
||||||
description: 'Set which trip members are splitting a budget item (replaces current member list). Ask the user which members share the expense; resolve user IDs with list_trip_members.',
|
description: 'Set which trip members are splitting a budget item (replaces current member list).',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
itemId: z.number().int().positive(),
|
itemId: z.number().int().positive(),
|
||||||
@@ -177,9 +149,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||||
const result = updateBudgetMembers(itemId, tripId, userIds);
|
const item = updateBudgetMembers(itemId, tripId, userIds);
|
||||||
if (!result) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
|
||||||
const item = getBudgetItem(itemId, tripId);
|
|
||||||
safeBroadcast(tripId, 'budget:members-updated', { item });
|
safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||||
return ok({ item });
|
return ok({ item });
|
||||||
}
|
}
|
||||||
@@ -206,110 +176,5 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
|||||||
return ok({ member });
|
return ok({ member });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- SETTLEMENTS (settle-up payments between members) ---
|
|
||||||
|
|
||||||
if (R) server.registerTool(
|
|
||||||
'get_settlement_summary',
|
|
||||||
{
|
|
||||||
description: "See each member's net balance and the suggested payments to settle shared expenses. Amounts are in the trip's base currency. Call this before recording a settlement so you know who should pay whom and how much.",
|
|
||||||
inputSchema: {
|
|
||||||
tripId: z.number().int().positive(),
|
|
||||||
base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'),
|
|
||||||
},
|
|
||||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
||||||
},
|
|
||||||
async ({ tripId, base }) => {
|
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
||||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?').get(tripId) as { currency?: string } | undefined;
|
|
||||||
const tripCurrency = trip?.currency || 'EUR';
|
|
||||||
const effectiveBase = (base || tripCurrency).toUpperCase();
|
|
||||||
const rates = await getRates(effectiveBase);
|
|
||||||
const summary = calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
|
||||||
return ok({ summary });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (R) server.registerTool(
|
|
||||||
'list_settlements',
|
|
||||||
{
|
|
||||||
description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).',
|
|
||||||
inputSchema: {
|
|
||||||
tripId: z.number().int().positive(),
|
|
||||||
},
|
|
||||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
||||||
},
|
|
||||||
async ({ tripId }) => {
|
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
||||||
return ok({ settlements: listSettlements(tripId) });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (W) server.registerTool(
|
|
||||||
'create_settlement',
|
|
||||||
{
|
|
||||||
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount (in the trip's base currency) to settle shared expenses. Use get_settlement_summary first to find who owes whom and how much.",
|
|
||||||
inputSchema: {
|
|
||||||
tripId: z.number().int().positive(),
|
|
||||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
|
||||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
|
||||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
|
||||||
},
|
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
||||||
},
|
|
||||||
async ({ tripId, from_user_id, to_user_id, amount }) => {
|
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
|
||||||
const settlement = createSettlement(tripId, { from_user_id, to_user_id, amount }, userId);
|
|
||||||
safeBroadcast(tripId, 'budget:settlement-created', { settlement });
|
|
||||||
return ok({ settlement });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (W) server.registerTool(
|
|
||||||
'update_settlement',
|
|
||||||
{
|
|
||||||
description: 'Update a recorded settle-up payment (who paid, who received, and the amount).',
|
|
||||||
inputSchema: {
|
|
||||||
tripId: z.number().int().positive(),
|
|
||||||
settlementId: z.number().int().positive(),
|
|
||||||
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
|
|
||||||
to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'),
|
|
||||||
amount: z.number().positive().describe("Amount paid, in the trip's base currency"),
|
|
||||||
},
|
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
||||||
},
|
|
||||||
async ({ tripId, settlementId, from_user_id, to_user_id, amount }) => {
|
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
|
||||||
const settlement = updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount });
|
|
||||||
if (!settlement) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
|
||||||
safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
|
|
||||||
return ok({ settlement });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (W) server.registerTool(
|
|
||||||
'delete_settlement',
|
|
||||||
{
|
|
||||||
description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.',
|
|
||||||
inputSchema: {
|
|
||||||
tripId: z.number().int().positive(),
|
|
||||||
settlementId: z.number().int().positive(),
|
|
||||||
},
|
|
||||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
|
||||||
},
|
|
||||||
async ({ tripId, settlementId }) => {
|
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
||||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
|
||||||
const deleted = deleteSettlement(settlementId, tripId);
|
|
||||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Settlement not found.' }], isError: true };
|
|
||||||
safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
|
|
||||||
return ok({ success: true });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} // isAddonEnabled(BUDGET)
|
} // isAddonEnabled(BUDGET)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,20 +99,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
|
||||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
notes: z.string().max(1000).optional(),
|
notes: z.string().max(1000).optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
|
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||||
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();
|
||||||
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||||
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
||||||
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||||
safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
||||||
return ok({ accommodation });
|
return ok({ accommodation });
|
||||||
}
|
}
|
||||||
@@ -131,7 +130,6 @@ 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(),
|
||||||
@@ -139,7 +137,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
|
||||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||||
@@ -148,7 +145,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, 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 }) => {
|
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_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();
|
||||||
@@ -156,8 +153,8 @@ 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, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
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 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_out, confirmation, notes: accommodation_notes });
|
||||||
return { place, accommodation };
|
return { place, accommodation };
|
||||||
});
|
});
|
||||||
const result = run();
|
const result = run();
|
||||||
@@ -181,20 +178,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
start_day_id: z.number().int().positive().optional(),
|
start_day_id: z.number().int().positive().optional(),
|
||||||
end_day_id: z.number().int().positive().optional(),
|
end_day_id: z.number().int().positive().optional(),
|
||||||
check_in: z.string().max(10).optional(),
|
check_in: z.string().max(10).optional(),
|
||||||
check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'),
|
|
||||||
check_out: z.string().max(10).optional(),
|
check_out: z.string().max(10).optional(),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
notes: z.string().max(1000).optional(),
|
notes: z.string().max(1000).optional(),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||||
},
|
},
|
||||||
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }) => {
|
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||||
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();
|
||||||
const existing = getAccommodation(accommodationId, tripId);
|
const existing = getAccommodation(accommodationId, tripId);
|
||||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||||
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||||
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
||||||
return ok({ accommodation });
|
return ok({ accommodation });
|
||||||
}
|
}
|
||||||
@@ -231,7 +227,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
dayId: z.number().int().positive(),
|
dayId: z.number().int().positive(),
|
||||||
text: z.string().min(1).max(500),
|
text: z.string().min(1).max(500),
|
||||||
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
@@ -256,7 +252,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
dayId: z.number().int().positive(),
|
dayId: z.number().int().positive(),
|
||||||
noteId: z.number().int().positive(),
|
noteId: z.number().int().positive(),
|
||||||
text: z.string().min(1).max(500).optional(),
|
text: z.string().min(1).max(500).optional(),
|
||||||
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||||
|
|||||||
@@ -136,9 +136,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
async ({ title, subtitle, trip_ids }) => {
|
async ({ title, subtitle, trip_ids }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
||||||
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
|
return ok({ journey });
|
||||||
// matching get_journey, rather than the bare row.
|
|
||||||
return ok({ journey: getJourneyFull(journey.id, userId) ?? journey });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -235,9 +233,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
|
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
|
||||||
if (!entry) return notFound('Journey not found or access denied.');
|
if (!entry) return notFound('Journey not found or access denied.');
|
||||||
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
|
return ok({ entry });
|
||||||
const enriched = listEntries(journeyId, userId)?.find(e => e.id === entry.id) ?? entry;
|
|
||||||
return ok({ entry: enriched });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -259,9 +255,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
||||||
if (!entry) return notFound('Entry not found or access denied.');
|
if (!entry) return notFound('Entry not found or access denied.');
|
||||||
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
|
return ok({ entry });
|
||||||
const enriched = listEntries(entry.journey_id, userId)?.find(e => e.id === entry.id) ?? entry;
|
|
||||||
return ok({ entry: enriched });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -370,8 +364,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
||||||
if (!result) return notFound('Journey not found or access denied.');
|
if (!result) return notFound('Journey not found or access denied.');
|
||||||
// Return the service result ({ hide_skeletons }), matching the REST route.
|
return ok({ success: true });
|
||||||
return ok(result);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ import {
|
|||||||
listBags, createBag, updateBag, deleteBag, setBagMembers,
|
listBags, createBag, updateBag, deleteBag, setBagMembers,
|
||||||
getCategoryAssignees as getPackingCategoryAssignees,
|
getCategoryAssignees as getPackingCategoryAssignees,
|
||||||
updateCategoryAssignees as updatePackingCategoryAssignees,
|
updateCategoryAssignees as updatePackingCategoryAssignees,
|
||||||
applyTemplate, saveAsTemplate, listTemplates, bulkImport,
|
applyTemplate, saveAsTemplate, bulkImport,
|
||||||
} from '../../services/packingService';
|
} from '../../services/packingService';
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||||
isAdminUser, adminRequired,
|
|
||||||
} from './_shared';
|
} from './_shared';
|
||||||
import { canRead, canWrite } from '../scopes';
|
import { canRead, canWrite } from '../scopes';
|
||||||
import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService';
|
import { isAddonEnabled } from '../../services/adminService';
|
||||||
import { ADDON_IDS } from '../../addons';
|
import { ADDON_IDS } from '../../addons';
|
||||||
|
|
||||||
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||||
@@ -172,9 +171,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
// createBag returns a bare row; hydrate with the empty members array that
|
const bag = createBag(tripId, { name, color });
|
||||||
// listBags and the schema always carry, so the client/AI consumer matches.
|
|
||||||
const bag = { ...(createBag(tripId, { name, color }) as object), members: [] };
|
|
||||||
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
||||||
return ok({ bag });
|
return ok({ bag });
|
||||||
}
|
}
|
||||||
@@ -200,10 +197,7 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
const bodyKeys: string[] = [];
|
const bodyKeys: string[] = [];
|
||||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||||
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
||||||
const updated = updateBag(tripId, bagId, fields, bodyKeys);
|
const bag = updateBag(tripId, bagId, fields, bodyKeys);
|
||||||
if (!updated) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
|
|
||||||
// Hydrate with the members array (matches create_packing_bag, listBags, and the schema).
|
|
||||||
const bag = listBags(tripId).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] };
|
|
||||||
safeBroadcast(tripId, 'packing:bag-updated', { bag });
|
safeBroadcast(tripId, 'packing:bag-updated', { bag });
|
||||||
return ok({ bag });
|
return ok({ bag });
|
||||||
}
|
}
|
||||||
@@ -244,10 +238,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
const members = setBagMembers(tripId, bagId, userIds);
|
setBagMembers(tripId, bagId, userIds);
|
||||||
if (!members) return { content: [{ type: 'text' as const, text: 'Bag not found.' }], isError: true };
|
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
|
||||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
|
return ok({ success: true });
|
||||||
return ok({ members });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -282,9 +275,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
const assignees = updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||||
safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
|
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
|
||||||
return ok({ assignees });
|
return ok({ success: true });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -302,32 +295,17 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
const items = applyTemplate(tripId, templateId);
|
const applied = applyTemplate(tripId, templateId);
|
||||||
if (items === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||||
safeBroadcast(tripId, 'packing:template-applied', { items });
|
safeBroadcast(tripId, 'packing:template-applied', { templateId });
|
||||||
return ok({ items, count: items.length });
|
return ok({ success: true });
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (R) server.registerTool(
|
|
||||||
'list_packing_templates',
|
|
||||||
{
|
|
||||||
description: 'List the reusable packing templates (id, name, item count) so one can be applied with apply_packing_template.',
|
|
||||||
inputSchema: {
|
|
||||||
tripId: z.number().int().positive(),
|
|
||||||
},
|
|
||||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
||||||
},
|
|
||||||
async ({ tripId }) => {
|
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
||||||
return ok({ templates: listTemplates() });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'save_packing_template',
|
'save_packing_template',
|
||||||
{
|
{
|
||||||
description: 'Save the current packing list as a reusable template. Returns the new template (id, name, category/item counts). Admin only.',
|
description: 'Save the current packing list as a reusable template.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
templateName: z.string().min(1).max(100),
|
templateName: z.string().min(1).max(100),
|
||||||
@@ -338,46 +316,21 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
// Templates are global; the REST route restricts saving to admins. Match it.
|
saveAsTemplate(tripId, userId, templateName);
|
||||||
if (!isAdminUser(userId)) return adminRequired();
|
return ok({ success: true });
|
||||||
const template = saveAsTemplate(tripId, userId, templateName);
|
|
||||||
if (!template) return { content: [{ type: 'text' as const, text: 'Nothing to save — the packing list is empty.' }], isError: true };
|
|
||||||
return ok({ template });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (W) server.registerTool(
|
|
||||||
'delete_packing_template',
|
|
||||||
{
|
|
||||||
description: 'Delete a reusable packing template. Templates are global, so deletion is admin only.',
|
|
||||||
inputSchema: {
|
|
||||||
templateId: z.number().int().positive(),
|
|
||||||
},
|
|
||||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
|
||||||
},
|
|
||||||
async ({ templateId }) => {
|
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
|
||||||
// Templates are global; the REST route restricts management to admins. Match it.
|
|
||||||
if (!isAdminUser(userId)) return adminRequired();
|
|
||||||
const result = deletePackingTemplate(String(templateId));
|
|
||||||
if ('error' in result) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
|
||||||
return ok({ success: true, name: result.name });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'bulk_import_packing',
|
'bulk_import_packing',
|
||||||
{
|
{
|
||||||
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
|
description: 'Import multiple packing items at once from a list.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
items: z.array(z.object({
|
items: z.array(z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
quantity: z.number().int().positive().optional(),
|
quantity: z.number().int().positive().optional(),
|
||||||
bag: z.string().max(100).optional().describe('Bag name to assign the item to; created if it does not exist'),
|
|
||||||
weight_grams: z.number().nonnegative().optional(),
|
|
||||||
checked: z.boolean().optional(),
|
|
||||||
})).min(1),
|
})).min(1),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
@@ -386,9 +339,9 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
||||||
const created = bulkImport(tripId, items);
|
bulkImport(tripId, items);
|
||||||
for (const item of created) safeBroadcast(tripId, 'packing:created', { item });
|
safeBroadcast(tripId, 'packing:updated', {});
|
||||||
return ok({ items: created, count: created.length });
|
return ok({ success: true, count: items.length });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, 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.',
|
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.',
|
||||||
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,7 +33,6 @@ 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(),
|
||||||
@@ -43,11 +42,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, google_ftid, osm_id, notes, website, phone, price, currency }) => {
|
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, 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, google_ftid, osm_id, notes, website, phone, price, currency });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
|
||||||
safeBroadcast(tripId, 'place:created', { place });
|
safeBroadcast(tripId, 'place:created', { place });
|
||||||
return ok({ place });
|
return ok({ place });
|
||||||
}
|
}
|
||||||
@@ -67,7 +66,6 @@ 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(),
|
||||||
@@ -78,14 +76,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, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, 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, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
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 assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||||
return { place, assignment };
|
return { place, assignment };
|
||||||
});
|
});
|
||||||
@@ -123,15 +121,14 @@ 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, google_ftid }) => {
|
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 }) => {
|
||||||
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, google_ftid });
|
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 });
|
||||||
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 });
|
||||||
@@ -199,7 +196,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/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.',
|
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.',
|
||||||
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'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
|
|
||||||
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||||
return ok({ reservation, accommodation_id: (reservation as any)?.accommodation_id ?? null });
|
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import { canAccessTrip } from '../../db/database';
|
|||||||
import { isDemoUser } from '../../services/authService';
|
import { isDemoUser } from '../../services/authService';
|
||||||
import {
|
import {
|
||||||
createReservation, deleteReservation, getReservation, updateReservation,
|
createReservation, deleteReservation, getReservation, updateReservation,
|
||||||
type EndpointInput,
|
|
||||||
} from '../../services/reservationService';
|
} from '../../services/reservationService';
|
||||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||||
import { getDay } from '../../services/dayService';
|
import { getDay } from '../../services/dayService';
|
||||||
import { findByIata } from '../../services/airportService';
|
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
|
||||||
@@ -17,56 +15,17 @@ import { canWrite } from '../scopes';
|
|||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
||||||
|
|
||||||
const endpointObjectSchema = z.object({
|
const endpointSchema = z.array(z.object({
|
||||||
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
||||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||||
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
||||||
lat: z.number().optional().describe('Latitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
|
lat: z.number().optional(),
|
||||||
lng: z.number().optional().describe('Longitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'),
|
lng: z.number().optional(),
|
||||||
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
|
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
|
||||||
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
|
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
|
||||||
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
|
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
|
||||||
});
|
})).optional();
|
||||||
const endpointSchema = z.array(endpointObjectSchema).optional();
|
|
||||||
|
|
||||||
type Endpoint = z.infer<typeof endpointObjectSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Endpoint coordinates are stored NOT NULL. Callers may supply a flight endpoint
|
|
||||||
* with only an IATA `code` (the tool description encourages this), so fill missing
|
|
||||||
* lat/lng/timezone from the airport database. Returns an error string for the first
|
|
||||||
* endpoint that can't be resolved rather than letting the NOT NULL bind throw.
|
|
||||||
*
|
|
||||||
* Normalizes to the service's EndpointInput shape (nullable fields coerced from the
|
|
||||||
* schema's optionals), so lat/lng are guaranteed present before the insert.
|
|
||||||
*/
|
|
||||||
function resolveEndpointCoords(endpoints: Endpoint[] | undefined): { endpoints: EndpointInput[] } | { error: string } {
|
|
||||||
if (!endpoints) return { endpoints: [] };
|
|
||||||
const out: EndpointInput[] = [];
|
|
||||||
for (const e of endpoints) {
|
|
||||||
const base = {
|
|
||||||
role: e.role,
|
|
||||||
sequence: e.sequence,
|
|
||||||
name: e.name,
|
|
||||||
code: e.code ?? null,
|
|
||||||
timezone: e.timezone ?? null,
|
|
||||||
local_time: e.local_time ?? null,
|
|
||||||
local_date: e.local_date ?? null,
|
|
||||||
};
|
|
||||||
if (e.lat != null && e.lng != null) { out.push({ ...base, lat: e.lat, lng: e.lng }); continue; }
|
|
||||||
if (e.code) {
|
|
||||||
const airport = findByIata(e.code);
|
|
||||||
if (airport) {
|
|
||||||
out.push({ ...base, lat: airport.lat, lng: airport.lng, timezone: e.timezone ?? airport.tz });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return { error: `Could not resolve airport code "${e.code}". Use search_airports to find a valid IATA code, or supply lat/lng directly.` };
|
|
||||||
}
|
|
||||||
return { error: `Endpoint "${e.name}" is missing coordinates. For flights set "code" to the IATA airport code; for other transport types supply lat/lng.` };
|
|
||||||
}
|
|
||||||
return { endpoints: out };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||||
if (!canWrite(scopes, 'reservations')) return;
|
if (!canWrite(scopes, 'reservations')) return;
|
||||||
@@ -104,9 +63,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
if (end_day_id && !getDay(end_day_id, tripId))
|
if (end_day_id && !getDay(end_day_id, tripId))
|
||||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||||
|
|
||||||
const resolved = resolveEndpointCoords(endpoints);
|
|
||||||
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
|
|
||||||
|
|
||||||
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||||
if (price != null) meta.price = String(price);
|
if (price != null) meta.price = String(price);
|
||||||
|
|
||||||
@@ -122,7 +78,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
end_day_id: end_day_id ?? start_day_id,
|
end_day_id: end_day_id ?? start_day_id,
|
||||||
status: status ?? 'pending',
|
status: status ?? 'pending',
|
||||||
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||||
endpoints: resolved.endpoints,
|
endpoints,
|
||||||
needs_review,
|
needs_review,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,14 +135,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
if (end_day_id && !getDay(end_day_id, tripId))
|
if (end_day_id && !getDay(end_day_id, tripId))
|
||||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||||
|
|
||||||
// Only resolve when endpoints are explicitly provided; undefined leaves them untouched.
|
|
||||||
let resolvedEndpoints: EndpointInput[] | undefined;
|
|
||||||
if (endpoints !== undefined) {
|
|
||||||
const resolved = resolveEndpointCoords(endpoints);
|
|
||||||
if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true };
|
|
||||||
resolvedEndpoints = resolved.endpoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { reservation } = updateReservation(reservationId, tripId, {
|
const { reservation } = updateReservation(reservationId, tripId, {
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
@@ -198,7 +146,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
end_day_id,
|
end_day_id,
|
||||||
status,
|
status,
|
||||||
metadata,
|
metadata,
|
||||||
endpoints: resolvedEndpoints,
|
endpoints,
|
||||||
needs_review,
|
needs_review,
|
||||||
}, existing);
|
}, existing);
|
||||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||||
|
|||||||
@@ -55,10 +55,8 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
|||||||
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
|
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const planId = getActivePlanId(userId);
|
const planId = getActivePlanId(userId);
|
||||||
// updatePlan already returns the fully-hydrated { plan }; surface it so the
|
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
||||||
// AI consumer sees the updated plan, matching get_vacay_plan.
|
return ok({ success: true });
|
||||||
const result = await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
|
||||||
return ok(result);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,8 +73,7 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
const planId = getActivePlanId(userId);
|
const planId = getActivePlanId(userId);
|
||||||
setUserColor(userId, planId, color, undefined);
|
setUserColor(userId, planId, color, undefined);
|
||||||
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
|
return ok({ success: true });
|
||||||
return ok({ success: true, color: color || '#6366f1' });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import compression from 'compression';
|
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
@@ -29,21 +28,6 @@ export function applyGlobalMiddleware(
|
|||||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
|
|
||||||
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
|
|
||||||
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
|
|
||||||
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
|
|
||||||
// they are excluded explicitly.
|
|
||||||
app.use(
|
|
||||||
compression({
|
|
||||||
filter: (req, res) => {
|
|
||||||
const type = res.getHeader('Content-Type');
|
|
||||||
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
|
|
||||||
return compression.filter(req, res);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
@@ -114,15 +98,12 @@ 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:"],
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||||
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
|
objectSrc: ["'none'"],
|
||||||
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
|
|
||||||
objectSrc: ["'self'"],
|
|
||||||
frameSrc: ["'none'"],
|
frameSrc: ["'none'"],
|
||||||
frameAncestors: ["'self'"],
|
frameAncestors: ["'self'"],
|
||||||
// Restrict <form> submission targets (form-action has no default-src
|
// Restrict <form> submission targets (form-action has no default-src
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user