Compare commits

..

9 Commits

Author SHA1 Message Date
github-actions[bot] 33a33e7b1d chore: bump version to 4.1.1 [skip ci] 2026-08-29 14:42:16 +00:00
Maurice 376332d989 fix(mobile): the phone admin map preview needs the CARTO key too
The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.
2026-08-29 16:40:54 +02:00
Maurice 43fbee94f9 fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar
Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.
2026-08-29 16:40:54 +02:00
Maurice 0b2cf1c5ef fix(mobile): put the CARTO key back on the phone map settings preview too
The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.
2026-08-29 16:40:54 +02:00
Maurice 241f152347 fix(admin): put the CARTO key back on the default-settings map preview too
Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.
2026-08-29 16:40:54 +02:00
Maurice 8c85323d6d fix(map): put the CARTO key back on the template the settings preview draws
The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.

Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.

FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.
2026-08-29 16:40:54 +02:00
Maurice 8635bec507 fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down
Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.
2026-08-29 16:40:54 +02:00
Maurice 57637cb447 fix(collections): ask the same question at all three call sites
savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.

COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.
2026-08-29 16:40:54 +02:00
michael-bohr df9e2d542a fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone
Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.

Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.

It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.

Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.

Closes #2137
2026-08-29 16:40:54 +02:00
20 changed files with 370 additions and 39 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.1.0
version: 4.1.1
description: Minimal Helm chart for TREK app
appVersion: "4.1.0"
appVersion: "4.1.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.1.0",
"version": "4.1.1",
"private": true,
"type": "module",
"scripts": {
@@ -8,7 +8,7 @@ import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place } from '../../types'
import { normalizeTileUrl } from '../../utils/tileUrl'
import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
@@ -378,7 +378,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
onMapContextMenu: null,
center: [48.8566, 2.3522],
zoom: 10,
tileUrl: mapTileUrl,
// Same as the user-facing map tab: the field holds what is being
// edited, so the key goes back on before the preview resolves it.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
+13 -3
View File
@@ -5,6 +5,7 @@ import { act, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
import { useAuthStore } from '../../store/authStore'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import * as photoService from '../../services/photoService'
@@ -48,9 +49,10 @@ vi.mock('../../hooks/useGeolocation', () => ({
const thumbCallbacks = vi.hoisted(() => new Map<string, (thumb: string) => void>())
vi.mock('react-leaflet', () => ({
// center/zoom are surfaced so tests can assert the camera the map is built with.
MapContainer: ({ children, center, zoom }: any) => (
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom}>{children}</div>
// center/zoom are surfaced so tests can assert the camera the map is built
// with; maxZoom because a cluster refuses to attach to a map without one.
MapContainer: ({ children, center, zoom, maxZoom }: any) => (
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom}>{children}</div>
),
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
@@ -245,6 +247,14 @@ describe('MapView', () => {
expect(screen.getByTestId('cluster-group')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-010b: the map carries a zoom ceiling of its own, whatever the basemap is', () => {
// Without it, a vector basemap leaves the map with none and the cluster
// above throws on attach, taking the planner to the error boundary. The
// ceiling has to sit on the map because only a GridLayer can contribute one.
render(<MapView places={[buildMapPlace({ lat: 48.8584, lng: 2.2945 })]} />)
expect(screen.getByTestId('map-container').getAttribute('data-maxzoom')).toBe(String(MAP_MAX_ZOOM))
})
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
render(<MapView route={route} />)
+7 -1
View File
@@ -19,7 +19,7 @@ import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
import { resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
@@ -832,6 +832,12 @@ export const MapView = memo(function MapView({
center={initialView.center}
zoom={initialView.zoom}
zoomControl={false}
// On the map itself, not left to the base layer. Leaflet reads its zoom
// ceiling from the map options or, failing that, from a GridLayer that
// brought one; a vector basemap is neither, so a map drawn by
// VectorBasemap had no ceiling at all. MarkerClusterGroup.onAdd throws
// outright on an infinite one, which took the whole planner down.
maxZoom={MAP_MAX_ZOOM}
className="w-full h-full bg-[#e5e7eb]"
>
{/* The basemap is a vector style by default and a raster template when the
@@ -0,0 +1,84 @@
/**
* The zoom ceiling a Leaflet map needs before a marker cluster will attach.
*
* This is a test about Leaflet's own semantics rather than about our components,
* because that semantic is what a basemap change quietly broke: switching the
* default basemap to a vector style replaced the TileLayer with a GL canvas, the
* ceiling went with it, and MarkerClusterGroup.onAdd threw on the way in. The
* planner and the map settings preview both went to the error boundary, which
* left no screen from which to pick a different basemap.
*
* Nothing is mocked here on purpose. A mocked react-leaflet would have kept
* passing throughout.
*/
import { describe, it, expect, afterEach } from 'vitest'
import L from 'leaflet'
import 'leaflet.markercluster'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
/**
* A layer that is not a GridLayer, which is what a vector basemap amounts to:
* maplibre-gl-leaflet hands back an L.Layer holding a canvas, so it has no
* beforeAdd and never reaches Leaflet's _addZoomLimit.
*/
const GlCanvasLayer = L.Layer.extend({ onAdd: () => undefined, onRemove: () => undefined })
function glCanvasLayer(): L.Layer {
return new GlCanvasLayer() as L.Layer
}
/**
* `leaflet.markercluster` ships no types and we do not depend on
* `@types/leaflet.markercluster`, so the one factory this file needs is named
* here rather than pulling a package in for a single call.
*/
const markerClusterGroup = (L as unknown as { markerClusterGroup: () => L.Layer }).markerClusterGroup
const containers: HTMLElement[] = []
function mapOn(options: L.MapOptions): L.Map {
const el = document.createElement('div')
Object.defineProperty(el, 'clientWidth', { value: 800 })
Object.defineProperty(el, 'clientHeight', { value: 600 })
document.body.appendChild(el)
containers.push(el)
return L.map(el, { center: [48.85, 2.35], zoom: 12, ...options })
}
afterEach(() => {
for (const el of containers.splice(0)) el.remove()
})
describe('the zoom ceiling of a Leaflet map', () => {
it('MAPZOOM-001: a vector basemap contributes none, so the map has none', () => {
const map = mapOn({})
glCanvasLayer().addTo(map)
// Only a GridLayer feeds Leaflet's _layersMaxZoom, via its beforeAdd hook.
expect(map.getMaxZoom()).toBe(Infinity)
})
it('MAPZOOM-002: a raster basemap contributes one, which is why raster maps never broke', () => {
const map = mapOn({})
L.tileLayer('https://example.test/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map)
expect(map.getMaxZoom()).toBe(19)
})
it('MAPZOOM-003: a cluster refuses to attach to a map with no ceiling', () => {
const map = mapOn({})
glCanvasLayer().addTo(map)
// The throw is in onAdd, before any clustering, so an empty trip hits it too.
expect(() => markerClusterGroup().addTo(map)).toThrow(/maxZoom/)
})
it('MAPZOOM-004: the ceiling on the map itself carries every basemap kind', () => {
const map = mapOn({ maxZoom: MAP_MAX_ZOOM })
glCanvasLayer().addTo(map)
expect(map.getMaxZoom()).toBe(MAP_MAX_ZOOM)
expect(() => markerClusterGroup().addTo(map)).not.toThrow()
})
it('MAPZOOM-005: it does not lower the raster and satellite layers, which already sat at 19', () => {
const map = mapOn({ maxZoom: MAP_MAX_ZOOM })
L.tileLayer('https://example.test/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map)
expect(map.getMaxZoom()).toBe(19)
})
})
@@ -8,10 +8,11 @@ import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import MapSettingsTab from './MapSettingsTab';
// Mock MapView to avoid Leaflet DOM issues in jsdom
// Mock MapView to avoid Leaflet DOM issues in jsdom. tileUrl is surfaced because
// the preview has to draw the basemap being configured, key included.
vi.mock('../Map/MapView', () => ({
MapView: ({ onMapClick }: { onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void }) => (
<div data-testid="map-view" onClick={() => onMapClick?.({ latlng: { lat: 51.5, lng: -0.1 } })} />
MapView: ({ onMapClick, tileUrl }: { onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void; tileUrl?: string }) => (
<div data-testid="map-view" data-tile-url={tileUrl} onClick={() => onMapClick?.({ latlng: { lat: 51.5, lng: -0.1 } })} />
),
}));
@@ -394,6 +395,27 @@ describe('MapSettingsTab – CARTO key', () => {
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
});
it('FE-COMP-MAP-033b: the preview draws the CARTO basemap being configured, not the app default', async () => {
// The fields hold what the user is editing, not what useTileUrl resolved, so
// the key has to be put back on before the preview reads the template.
// Without it the preview resolves a keyless CARTO url, silently falls back to
// the default vector style, and shows a basemap nobody chose.
const user = userEvent.setup();
seedStore(useSettingsStore, {
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES, carto_api_key: 'demo-key' }),
});
render(<MapSettingsTab />);
await waitFor(() => {
expect(screen.getByTestId('map-view').getAttribute('data-tile-url')).toContain('key=demo-key');
});
await user.clear(cartoInput());
await waitFor(() => {
expect(screen.getByTestId('map-view').getAttribute('data-tile-url')).not.toContain('key=');
});
});
it('FE-COMP-MAP-034: a CARTO template without a key explains the watermark until one is typed', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, {
@@ -11,6 +11,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { withTileApiKey } from '../../utils/tileUrl'
import type { Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
@@ -473,7 +474,12 @@ export default function MapSettingsTab(): React.ReactElement {
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
tileUrl: mapTileUrl,
// With the key on it, or the preview resolves the template as a
// keyless CARTO one and quietly shows the app default instead of
// the basemap being configured. The fields hold what the user is
// editing rather than what useTileUrl already resolved, so the key
// has to be put back on here.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
+12
View File
@@ -3,6 +3,18 @@ export const DEFAULT_MAP_LNG = 0
export const DEFAULT_MAP_ZOOM = 2
export const DEFAULT_MAP_CENTER: [number, number] = [DEFAULT_MAP_LAT, DEFAULT_MAP_LNG]
/**
* Zoom ceiling for a Leaflet map, set on the map rather than on its base layer.
*
* Leaflet answers `getMaxZoom()` from the map options first and only then from a
* layer that carried one, and only a GridLayer ever contributes: a vector
* basemap is a GL canvas, so a map drawn by one has no ceiling from anywhere.
* `MarkerClusterGroup.onAdd` refuses an infinite ceiling by throwing, which is
* how a basemap choice could take down the whole planner. Matches the raster and
* satellite layers so nothing changes for the maps that already had one.
*/
export const MAP_MAX_ZOOM = 19
// Tokenless satellite base layer (ESRI World Imagery) — works without an API key.
export const SATELLITE_TILE_URL =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
@@ -6,7 +6,7 @@ import { useToast } from '../../../components/shared/Toast'
import { MapView } from '../../../components/Map/MapView'
import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.constants'
import { getApiErrorMessage, type DistanceUnit, type Place } from '../../../types'
import { normalizeTileUrl } from '../../../utils/tileUrl'
import { normalizeTileUrl, withTileApiKey } from '../../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
@@ -331,7 +331,9 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
onMapContextMenu: null,
center: [48.8566, 2.3522],
zoom: 10,
tileUrl: mapTileUrl,
// As on the other three previews: the field holds what is being
// edited, so the key goes back on before the template resolves.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
@@ -17,6 +17,7 @@ import {
normalizeStyleForProvider,
type GlMapProvider,
} from '../../../components/Map/glProviders'
import { withTileApiKey } from '../../../utils/tileUrl'
import MToggle from '../../components/MToggle'
import { MSetCard, MSetEyebrow, MSetSelectRow, MSetInput, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MSetPickerSheet from './MSetPickerSheet'
@@ -318,7 +319,9 @@ export default function MSettingsMap() {
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
tileUrl: mapTileUrl,
// As on the desktop tab: the field holds what is being edited, so the
// key goes back on before the preview resolves the template.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
+4 -1
View File
@@ -19,7 +19,7 @@ import { createElement, useEffect, useRef } from 'react';
import { renderIconMarkup } from '../utils/iconMarkup';
import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
import { getCategoryIcon } from '../components/shared/categoryIcons';
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, attributionForTile } from '../constants/mapDefaults';
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults';
import VectorBasemap from '../components/Map/VectorBasemap';
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
@@ -489,6 +489,9 @@ export default function SharedTripPage() {
center={initialView.center}
zoom={initialView.zoom}
zoomControl={false}
// Same reason as the planner map: a vector basemap contributes
// no zoom ceiling, and fitBounds below asks for one.
maxZoom={MAP_MAX_ZOOM}
style={{ width: '100%', height: '100%' }}
>
{basemap.kind === 'vector' ? (
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@trek/root",
"version": "4.1.0",
"version": "4.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "4.1.0",
"version": "4.1.1",
"workspaces": [
"client",
"server",
@@ -25,7 +25,7 @@
},
"client": {
"name": "@trek/client",
"version": "4.1.0",
"version": "4.1.1",
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
@@ -21742,7 +21742,7 @@
},
"server": {
"name": "@trek/server",
"version": "4.1.0",
"version": "4.1.1",
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/s3": "^0.2.7",
@@ -22110,7 +22110,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "4.1.0",
"version": "4.1.1",
"dependencies": {
"isomorphic-dompurify": "^3.15.0",
"zod": "^4.3.6"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "4.1.0",
"version": "4.1.1",
"workspaces": [
"client",
"server",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "4.1.0",
"version": "4.1.1",
"main": "src/index.ts",
"scripts": {
"start": "node --require tsconfig-paths/register dist/index.js",
+19 -1
View File
@@ -156,6 +156,9 @@ export class CalendarService {
.prepare(
`SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng,
sd.date AS stay_start_date, ed.date AS stay_end_date,
a.check_in AS stay_check_in, a.check_out AS stay_check_out,
(SELECT MIN(r2.id) FROM reservations r2
WHERE r2.accommodation_id = a.id) AS stay_first_reservation_id,
rd.date AS day_date, red.date AS end_day_date
FROM reservations r
LEFT JOIN places pl ON r.place_id = pl.id
@@ -343,13 +346,28 @@ export class CalendarService {
// nights it contains. DTEND is exclusive, so it is check-out plus one day.
// Dropping that +1 hides the departure day; it is not a stray off-by-one
// (#1869).
if (isDate(r.stay_start_date)) {
// Unless the stay records BOTH ends of its clock, in which case the two
// timed markers below already say everything the block would, and saying
// it twice buries a week of calendar under a bar nobody can act on
// (#2136). Knowing only one end still needs the block: it is the only
// thing carrying the other end's date.
//
// Only for the booking the markers actually stand in for, though. They are
// emitted once per stay and titled from its lowest-id reservation, so a
// second room on the same stay is represented by nothing else and keeps
// its block.
const markersCover = isTime(r.stay_check_in) && isTime(r.stay_check_out)
&& r.stay_first_reservation_id === r.id;
if (isDate(r.stay_start_date) && !markersCover) {
const lastDay = isDate(r.stay_end_date) && r.stay_end_date >= r.stay_start_date
? r.stay_end_date
: r.stay_start_date;
return `DTSTART;VALUE=DATE:${fmtDate(r.stay_start_date)}\r\n` +
`DTEND;VALUE=DATE:${fmtDate(addDays(lastDay, 1))}\r\n`;
}
// A fully timed stay is carried by its markers alone, so the booking row
// itself has nothing left to place.
if (isDate(r.stay_start_date)) return null;
const eps = endpointsMap.get(r.id);
const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : null;
@@ -12,6 +12,7 @@ import {
trackInsertedInDedupSet,
type DedupSet,
} from '../places/places.helpers';
import { placeMatchStrategies, type PlaceMatchCandidate } from '@trek/shared';
import type {
Collection,
CollectionDetailResponse,
@@ -418,26 +419,48 @@ export class CollectionsService {
// Dedup (collection-scoped ports of placeService helpers)
// -------------------------------------------------------------------------
/**
* A third hand-written copy of the place-matching order (alongside
* PlacesService.findDuplicatePlace and isPlaceDuplicate), and the one that had
* actually drifted live rather than latently: unlike findDuplicatePlace's one
* caller, savePlace calls this directly with no isPlaceDuplicate guard in
* front, so a named candidate that matched nothing by name fell through to a
* coordinate match unconditionally — merging the restaurant and the bar at one
* address. It also never read google_place_id/google_ftid/osm_id at all, even
* though every collection_places row stores them, so a renamed place with no
* matching name or coordinates could be saved again under its old id.
*
* Now walks the shared strategy list from @trek/shared, same as
* PlacesService.findMatchingPlaceId: provider id, then name, then coordinates
* and only when there is no name.
*/
private findDuplicateCollectionPlace(
collectionId: number,
candidate: { name: string | null | undefined; lat: number | null | undefined; lng: number | null | undefined },
candidate: PlaceMatchCandidate,
): { id: number; name: string } | null {
const normalizedName = candidate.name?.trim().toLowerCase();
if (normalizedName) {
const dup = this.db.get<{ id: number; name: string }>(`
for (const strategy of placeMatchStrategies(candidate)) {
let hit: { id: number; name: string } | undefined;
if (strategy.by === 'externalId') {
hit = this.db.get<{ id: number; name: string }>(`
SELECT id, name FROM collection_places
WHERE collection_id = ? AND (google_place_id = ? OR google_ftid = ? OR osm_id = ?)
ORDER BY id ASC LIMIT 1
`, collectionId, strategy.id, strategy.id, strategy.id);
} else if (strategy.by === 'name') {
hit = this.db.get<{ id: number; name: string }>(`
SELECT id, name FROM collection_places
WHERE collection_id = ? AND lower(trim(name)) = ?
ORDER BY id ASC LIMIT 1
`, collectionId, normalizedName);
if (dup) return dup;
}
if (candidate.lat != null && candidate.lng != null) {
return this.db.get<{ id: number; name: string }>(`
`, collectionId, strategy.name);
} else {
hit = this.db.get<{ id: number; name: string }>(`
SELECT id, name FROM collection_places
WHERE collection_id = ? AND lat IS NOT NULL AND lng IS NOT NULL
AND abs(lat - ?) <= ? AND abs(lng - ?) <= ?
ORDER BY id ASC LIMIT 1
`, collectionId, candidate.lat, COORD_DEDUP_TOLERANCE, candidate.lng, COORD_DEDUP_TOLERANCE) || null;
`, collectionId, strategy.lat, strategy.tolerance, strategy.lng, strategy.tolerance);
}
if (hit) return hit;
}
return null;
}
@@ -491,7 +514,10 @@ export class CollectionsService {
this.assertCanEdit(userId, body.collection_id);
if (!body.force) {
const dup = this.findDuplicateCollectionPlace(body.collection_id, { name: body.name, lat: body.lat, lng: body.lng });
const dup = this.findDuplicateCollectionPlace(body.collection_id, {
name: body.name, lat: body.lat, lng: body.lng,
google_place_id: body.google_place_id, google_ftid: body.google_ftid, osm_id: body.osm_id,
});
if (dup) return { duplicate: true, duplicateOf: dup };
}
@@ -584,8 +610,10 @@ export class CollectionsService {
const rows = this.db.all<{
place_id: number; name: string; address: string | null; lat: number | null; lng: number | null;
category_id: number | null; image_url: string | null; day_number: number | null; date: string | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null;
}>(`
SELECT p.id AS place_id, p.name, p.address, p.lat, p.lng, p.category_id, p.image_url,
p.google_place_id, p.google_ftid, p.osm_id,
(SELECT MIN(d.day_number) FROM day_assignments da
JOIN days d ON d.id = da.day_id
WHERE da.place_id = p.id AND d.trip_id = p.trip_id) AS day_number,
@@ -601,7 +629,9 @@ export class CollectionsService {
return {
places: rows.map(r => ({
...r,
already_in_list: this.findDuplicateCollectionPlace(collectionId, { name: r.name, lat: r.lat, lng: r.lng }) != null,
// Asked with the same candidate the save will use, or the picker marks a
// place as new and the save then refuses it as a duplicate.
already_in_list: this.findDuplicateCollectionPlace(collectionId, r) != null,
scheduled: r.day_number != null,
})),
};
@@ -634,7 +664,16 @@ export class CollectionsService {
const name = p.name as string;
const lat = (p.lat as number | null) ?? null;
const lng = (p.lng as number | null) ?? null;
if (!force && this.findDuplicateCollectionPlace(collectionId, { name, lat, lng })) {
// The provider ids go with it: they are already carried into the insert
// below, so leaving them out here would recognise less than the row that
// gets written knows about.
const candidate = {
name, lat, lng,
google_place_id: (p.google_place_id as string | null) ?? null,
google_ftid: (p.google_ftid as string | null) ?? null,
osm_id: (p.osm_id as string | null) ?? null,
};
if (!force && this.findDuplicateCollectionPlace(collectionId, candidate)) {
skipped.push({ id: placeId, name });
continue;
}
@@ -829,6 +829,57 @@ describe('accommodations', () => {
expect(ics).toContain('SUMMARY:Hotel Bellevue');
});
it('CAL-025b: a stay that records both ends of its clock drops the all-day block (#2136)', () => {
// The two markers already say when to arrive and when to leave, which is the
// part a subscriber can act on. Keeping the block as well buries the week
// under a bar that repeats what the markers say.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', check_out: '11:00',
});
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713');
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue');
});
it('CAL-025d: a second room on the same stay keeps its block, since no marker names it', () => {
// The markers are emitted once per stay and titled from its lowest-id
// booking, so dropping every block would leave the second one with nothing.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
const { stayId } = createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', check_out: '11:00',
});
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY id ASC LIMIT 1').get(trip.id) as { id: number };
testDb.prepare(`
INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id)
VALUES (?, ?, 'Bellevue second room', NULL, 'confirmed', 'hotel', ?)
`).run(trip.id, day.id, String(stayId));
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Bellevue second room');
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
});
it('CAL-025c: knowing only one end keeps the block, since nothing else carries the other', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_in: '15:00' });
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713');
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
expect(ics).not.toContain('SUMMARY:Check-out');
});
it('CAL-026: check-in and check-out become their own timed events in the stay zone', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
@@ -179,6 +179,79 @@ describe('saved places + dedup', () => {
const col = svc.createCollection(a.id, { name: 'Locked' });
expect(() => svc.savePlace(b.id, { collection_id: col.id, name: 'X' })).toThrow();
});
it('COLLECTIONS-SVC-100: a NAMED candidate does not merge into a different place at the same coordinates', () => {
// The wrong-city hazard: findDuplicateCollectionPlace used to fall through to
// a coordinate match for a named candidate whose name did not match anything,
// which would report two distinct places at one address (the restaurant and
// the bar) as duplicates of each other.
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Berlin' });
svc.savePlace(u.id, { collection_id: col.id, name: 'Ground Floor Diner', lat: 52.52, lng: 13.405 });
const result = svc.savePlace(u.id, { collection_id: col.id, name: 'Rooftop Bar', lat: 52.52, lng: 13.405 });
expect(result.duplicate).toBeFalsy();
expect(result.place).toBeDefined();
});
it('COLLECTIONS-SVC-101: a provider id still recognises a renamed place a name/coords search would miss', () => {
// google_place_id/google_ftid/osm_id are stored on every collection_places row
// but were never read back for dedup, so a renamed place with no matching
// name or coordinates could be saved again under its old provider id.
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Renames' });
svc.savePlace(u.id, {
collection_id: col.id,
name: 'Original Name',
lat: 1,
lng: 1,
google_place_id: 'ChIJ_abc',
});
const result = svc.savePlace(u.id, {
collection_id: col.id,
name: 'Renamed By User',
lat: 2,
lng: 2,
google_place_id: 'ChIJ_abc',
});
expect(result.duplicate).toBe(true);
expect(result.duplicateOf?.name).toBe('Original Name');
});
it('COLLECTIONS-SVC-102: the bulk import recognises a renamed place by its provider id too', () => {
// savePlace was not the only caller. The bulk copy carries the provider ids
// into the row it writes, so asking without them would recognise less than
// the row it just wrote already knows.
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Rome' });
const trip = createTrip(testDb, u.id);
const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' });
testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id);
svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' });
const out = svc.saveFromTripPlaces(u.id, col.id, trip.id, [place.id]);
expect(out.copied).toBe(0);
expect(out.skipped.map(s => s.name)).toEqual(['Trattoria da Enzo']);
});
it('COLLECTIONS-SVC-103: the import picker marks that same place as already saved', () => {
// The dialog and the import have to agree: a row shown as new that the import
// then refuses is the drift this method exists to prevent.
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Rome' });
const trip = createTrip(testDb, u.id);
const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' });
testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id);
svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' });
const listed = svc.importablePlaces(u.id, col.id, trip.id).places.find(p => p.place_id === place.id);
expect(listed?.already_in_list).toBe(true);
});
});
// ── save-from-trip provenance + IDOR ─────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "4.1.0",
"version": "4.1.1",
"private": true,
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
"type": "module",