Compare commits

..

24 Commits

Author SHA1 Message Date
jubnl a63e16fb65 fix(airtrail): don't use cabin class as seat on import
When an AirTrail flight has a cabin class but no seat number, the mapper
fell back to the class for metadata.seat, so reservations showed e.g.
"economy" as the seat. Use only the seat number; leave the seat blank
otherwise. The class is still surfaced separately in the import picker.

Closes #1246
2026-06-18 14:12:41 +02:00
jubnl dfe98a057c chore(prettier) prettier this file 2026-06-18 14:05:19 +02:00
jubnl d5850041a7 fix(costs): rework the cost panel UX wise and apply prettier on the shared package 2026-06-18 13:59:10 +02:00
jubnl ad6e1ddcc8 fix(airtrail): add back missing tests 2026-06-18 10:04:33 +02:00
jubnl 66f661e2a1 fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss 2026-06-18 09:59:14 +02:00
Maurice 17b4f72be6 fix(dashboard): never crash on a malformed reservation date
A reservation with an invalid date blanked the whole My Trips page: the old
Upcoming widget did new Date(value).toISOString(), which throws "Invalid time
value" (fixed in #1222 by reading the string parts). Also guard splitDate so a
bad date renders a dash instead of "Invalid Date" or throwing.
2026-06-17 23:26:59 +02:00
Maurice 7aefeb4c53 fix(atlas): give every sub-national region a distinct code (#1217)
geoBoundaries fills shapeISO with the bare country code for some countries (every
Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one
region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when
it is a real "XX-..." subdivision code and otherwise synthesizes a unique
per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/
Chile/Oman now carry distinct region codes (countries with real codes, e.g.
Germany, are unchanged).
2026-06-17 23:19:51 +02:00
Maurice 63fb5a9c89 feat(admin): let admins set a default currency for new users
Adds a currency picker to Admin > User Defaults. Stored as the default_currency
user-default, so users who have not picked their own currency inherit it in
Costs.
2026-06-17 23:12:30 +02:00
Maurice 17245c5a8c fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225)
The optimistic mark/unmark updates bumped the country total but never the
per-continent counts, so the continent column froze until a full reload. Move
the country to continent map into @trek/shared (single source for server and
client) and adjust the matching continent count at every optimistic site: the
country confirm flow plus the choose / region mark and region unmark handlers.
2026-06-17 23:12:30 +02:00
Maurice 6ab4989c38 fix(planner): let a booking's day follow its date when edited (#1237)
Preserving the old day_id on edit left a re-dated booking on its previous start
day while end_day_id followed the new date, so it spanned both. Stop sending
day_id from the edit modal entirely - the server derives both ends from the
booking's date (and keeps the current day when there is no date), so a re-dated
booking moves cleanly to the matching day.
2026-06-17 22:38:58 +02:00
Maurice ea7f7fd9f3 fix(planner): derive a booking day from its date when none is set (#1237)
The client always sends day_id on a reservation update, so the server only
derived it from reservation_time when the field was absent. A non-transport
booking saved without a selected day (Book tab) therefore got day_id null and
vanished from the Plan, even though its date matched a day. Derive the day from
reservation_time whenever day_id is null, mirroring create.
2026-06-17 22:32:03 +02:00
Maurice 00738c8dbc fix(planner): keep a reservation on its day when edited (#1237)
Editing a booking forced its day_id to the globally selected day, which is null
when editing from the Book tab - so the booking lost its day and vanished from
the Plan. Preserve the reservation own day_id on edit instead.
2026-06-17 22:27:54 +02:00
Maurice 438f71bbc6 test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync
The service now leaves a linked expense alone when no budget entry is on the
payload (only an explicit total_price 0 deletes it) and syncs the category on a
booking type change. Update the unit tests accordingly - the old "price cleared"
case passed entry: undefined, which is now a no-op and left a mocked return
queued that leaked into the next test.
2026-06-17 22:27:53 +02:00
Maurice c15c89ca61 feat(costs): create an expense from a booking, fix editing total-only items
Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.

Also fix the Costs editor so an expense with a recorded total but no payers
(transport-derived or pre-rework items) opens with its amount, lets you set the
currency, and saves - it previously showed 0 everywhere and could not be saved.
Legacy / localized categories now map to the fixed keys, and changing a booking's
type keeps its linked expense category in sync (unless it was manually set).

- shared: reservation_id on budget create, typeToCostCategory helper, i18n keys
- server: createBudgetItem stores reservation_id; keep total_price for payerless
  items; a booking update no longer wipes its linked expense and syncs the
  category on type change
- client: shared BookingCostsSection, exported ExpenseModal with prefill and an
  editable total, page-level save-then-open wiring
2026-06-17 22:11:56 +02:00
Maurice f98058a3af feat(backup): make the upload size limit configurable
The restore upload was capped at a hard-coded 500 MB, so instances whose
backup archive (uploads/ included) grew past that got a 413 "File too large"
with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500,
invalid values warn and fall back), documented in .env.example.
2026-06-17 21:00:36 +02:00
Maurice 39a3ee7ce7 fix(collab): show poll option labels in the UI
The poll API formatted each option as { label, voters }, but the React poll
component renders opt.text - so every option button came out blank. Emit text
alongside label (kept for any other consumer) so options render again.
2026-06-17 21:00:19 +02:00
Maurice e09849d5b4 fix(oidc): keep dots in generated usernames
The OIDC username sanitizer stripped dots because they were missing from the
allowed character class, so a name claim like "first.last" became "firstlast".
Dots are valid usernames (the profile validator already allows
^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer.
2026-06-17 21:00:04 +02:00
Maurice b3fc5411ca fix(atlas): cursor-following tooltips and removing countries from search
Two related Atlas fixes:

- Country tooltips were bound with sticky:false, which anchors them at the
  feature's bounds centre. For countries with overseas territories (e.g.
  France) that centre sits far out in the ocean, so the tooltip popped up
  nowhere near the area being hovered. Make them sticky so they track the
  cursor.

- Selecting an already-visited country from the search bar always opened the
  "Mark / Bucket" dialog, with no way to remove it. Tiny countries like
  Vatican City or Singapore are hard to hit on the map, so search was the only
  way in. Mirror the map-click behaviour: a manually-marked country opens the
  Remove confirmation, a trip/place-backed one opens its detail.
2026-06-17 15:23:54 +02:00
Maurice f524909008 fix(dashboard): show the correct reservation date regardless of timezone
The upcoming-reservations widget built the date with new Date(reservation_time)
.toISOString(), which reinterprets the stored naive local time as UTC and can
roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation
showing the next day). Read the date and time straight from the stored string
parts via splitReservationDateTime, and format the time with the shared
formatTime helper so it also honours the user's 12h/24h preference.
2026-06-17 15:23:35 +02:00
Maurice 264cf7d384 fix(vacay): keep the mode toolbar above the mobile bottom nav
The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on
mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden
behind it - and could scroll out of reach entirely. Pin it above the nav with
the shared --bottom-nav-h variable (0px on desktop, so nothing changes there)
and reserve matching space below the calendar grid so it never gets swallowed.
2026-06-17 15:23:23 +02:00
Maurice cb7ce7f229 fix(docker): ship the encryption-key migration script in the image
The production image only copied server/dist, so the documented rotation
command `node --import tsx scripts/migrate-encryption.ts` failed inside the
container with a module-not-found error - the raw .ts was never present. The
script runs via tsx straight from source and only pulls node builtins plus
better-sqlite3 (both prod deps), so copying the single file into
/app/server/scripts is enough to make the rotation work again.
2026-06-17 15:04:29 +02:00
Maurice d40c5ce7a6 fix(demo): skip first-run admin seed in demo mode
When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app,
username "admin") right after the generic seeds run. The first-run admin
bootstrap was grabbing username "admin" first, so the demo seeder hit the
UNIQUE(username) constraint and aborted before the demo user was ever created
- which surfaced as a 500 "Demo user not found" on demo-login. Skip the
generic admin bootstrap when demo mode owns the admin account.
2026-06-17 15:01:41 +02:00
jubnl 2d79254c33 feat(pdf): add legs to pdf export 2026-06-17 11:05:35 +02:00
jubnl e6fcbc7789 fix(shared-view): render each leg of multi-leg flights correctly
The read-only shared view showed the overall trip start/end airports and
the first leg's flight number on every leg of a multi-leg flight. The Day
Plan already expands legs (each carries __leg), but the renderer ignored it
and read flat top-level metadata; the Bookings tab had the same bug.

- Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time
- Bookings tab: list each leg via getFlightLegs()
- unique React keys for multi-leg rows

Closes #1219
2026-06-17 10:44:05 +02:00
300 changed files with 2357 additions and 10018 deletions
-1
View File
@@ -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/
-2
View File
@@ -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
-9
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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"
-6
View File
@@ -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 }}
-14
View File
@@ -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 }}
-11
View File
@@ -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
View File
@@ -13,7 +13,7 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.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
View File
@@ -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"
} }
-1
View File
@@ -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([])
})
}) })
+11 -23
View File
@@ -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)} />
+35 -63
View File
@@ -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' }}
+4 -10
View File
@@ -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(() => {
+4 -19
View File
@@ -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()
})
}) })
+39 -71
View File
@@ -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],
])
})
})
+8 -38
View File
@@ -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')
})
})
-87
View File
@@ -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)
-22
View File
@@ -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,
+10 -18
View File
@@ -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)} &nbsp;{formatElevation(totalDown, distanceUnit)} {Math.round(totalUp)} m &nbsp;{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,
+1 -3
View File
@@ -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 => (
+8 -63
View File
@@ -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 }
} }
-1
View File
@@ -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
+3 -5
View File
@@ -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;
+2 -73
View File
@@ -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']);
});
});
}); });
+16 -74
View File
@@ -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) => {
+1 -30
View File
@@ -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());
});
});
}); });
+1 -1
View File
@@ -196,7 +196,7 @@ export default function SharedTripPage() {
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}> style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
<div className={selectedDay === day.id ? 'bg-[#111827] text-white' : 'bg-[#f3f4f6] text-[#6b7280]'} style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div> <div className={selectedDay === day.id ? 'bg-[#111827] text-white' : 'bg-[#f3f4f6] text-[#6b7280]'} style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div className="text-[#111827]" style={{ fontSize: 14, fontWeight: 600 }}>{day.title || 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) => (
+29 -8
View File
@@ -5,7 +5,7 @@ import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore' import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto' import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, 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} />
+5 -18
View File
@@ -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"
+2 -5
View File
@@ -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
+1 -12
View File
@@ -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
}
+2 -13
View File
@@ -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,
+5 -23
View File
@@ -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 })
} }
} }
}, },
-6
View File
@@ -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,
+2 -29
View File
@@ -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
View File
@@ -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 {
-36
View File
@@ -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)
})
}) })
+1 -8
View File
@@ -12,7 +12,7 @@ export const getDayBookendHotels = (
day: Day, day: Day,
days: Day[], days: Day[],
accommodations: Accommodation[], accommodations: Accommodation[],
): { morning?: Accommodation; evening?: Accommodation; 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),
} }
} }
-46
View File
@@ -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')
})
})
})
-35
View File
@@ -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(() =>
+1 -2
View File
@@ -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: 'العربية' }))
}) })
}) })
-12
View File
@@ -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
+1306 -1453
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 })`. */
-17
View File
@@ -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) {
-1
View File
@@ -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
View File
@@ -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 };
+1 -1
View File
@@ -48,7 +48,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls. **Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
**Adding a place to the itinerary (correct order):** **Adding a place to the itinerary (correct order):**
1. \`search_place\` — find the real-world POI; note the \`osm_id\`, \`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\`.
-11
View File
@@ -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) }] };
} }
+1 -5
View File
@@ -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
View File
@@ -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)
} }
+9 -13
View File
@@ -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,
+4 -11
View File
@@ -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);
} }
); );
+21 -68
View File
@@ -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 });
} }
); );
} }
+8 -11
View File
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool( if (W) server.registerTool(
'create_place', 'create_place',
{ {
description: 'Add a new place/POI to a trip. Set google_place_id, 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'),
}, },
+1 -1
View File
@@ -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 });
} }
); );
} }
+6 -58
View File
@@ -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 });
+3 -6
View File
@@ -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' });
} }
); );
+2 -21
View File
@@ -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