From c87e6d2fc2560c4afbf004eb7fa01d70bdfececf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 29 Aug 2026 11:16:19 +0000 Subject: [PATCH 01/10] chore: bump version to 4.1.0 [skip ci] --- charts/trek/Chart.yaml | 4 ++-- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 0cca3367b..330a62642 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 4.0.0 +version: 4.1.0 description: Minimal Helm chart for TREK app -appVersion: "4.0.0" +appVersion: "4.1.0" diff --git a/client/package.json b/client/package.json index edf11fccb..450df0131 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@trek/client", - "version": "4.0.0", + "version": "4.1.0", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 3f4c4d013..3a71f5ba4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@trek/root", - "version": "4.0.0", + "version": "4.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trek/root", - "version": "4.0.0", + "version": "4.1.0", "workspaces": [ "client", "server", @@ -25,7 +25,7 @@ }, "client": { "name": "@trek/client", - "version": "4.0.0", + "version": "4.1.0", "dependencies": { "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", @@ -21742,7 +21742,7 @@ }, "server": { "name": "@trek/server", - "version": "4.0.0", + "version": "4.1.0", "dependencies": { "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", @@ -22110,7 +22110,7 @@ }, "shared": { "name": "@trek/shared", - "version": "4.0.0", + "version": "4.1.0", "dependencies": { "isomorphic-dompurify": "^3.15.0", "zod": "^4.3.6" diff --git a/package.json b/package.json index 3ff4cf521..a2a5eb5e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@trek/root", "private": true, - "version": "4.0.0", + "version": "4.1.0", "workspaces": [ "client", "server", diff --git a/server/package.json b/server/package.json index 269299188..e8d060847 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@trek/server", - "version": "4.0.0", + "version": "4.1.0", "main": "src/index.ts", "scripts": { "start": "node --require tsconfig-paths/register dist/index.js", diff --git a/shared/package.json b/shared/package.json index 6ce8afd5d..f639c12ef 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@trek/shared", - "version": "4.0.0", + "version": "4.1.0", "private": true, "description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.", "type": "module", From df9e2d542a15fa0dd98941f0509277fe1601994a Mon Sep 17 00:00:00 2001 From: michael-bohr <263459669+michael-bohr@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:00:42 -0400 Subject: [PATCH 02/10] fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../nest/collections/collections.service.ts | 48 ++++++++++++++----- .../unit/nest/collections.service.test.ts | 41 ++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 51f3a0349..200ed40d0 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -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 }; } diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index ba0abe91c..60309ee88 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -179,6 +179,47 @@ 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'); + }); }); // ── save-from-trip provenance + IDOR ───────────────────────────────────────── From 57637cb447b25a231f40a4566543ac5daf3804b7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 16:04:00 +0200 Subject: [PATCH 03/10] 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. --- .../nest/collections/collections.service.ts | 17 ++++++++-- .../unit/nest/collections.service.test.ts | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 200ed40d0..2c8b651e4 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -610,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, @@ -627,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, })), }; @@ -660,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; } diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 60309ee88..2ec3c1f38 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -220,6 +220,38 @@ describe('saved places + dedup', () => { 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 ───────────────────────────────────────── From 8635bec5070d11ad7d70ebe9a583f7901e1bbd1e Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 15:34:18 +0200 Subject: [PATCH 04/10] 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. --- client/src/components/Map/MapView.test.tsx | 16 +++- client/src/components/Map/MapView.tsx | 8 +- .../src/components/Map/mapZoomCeiling.test.ts | 84 +++++++++++++++++++ client/src/constants/mapDefaults.ts | 12 +++ client/src/pages/SharedTripPage.tsx | 5 +- 5 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 client/src/components/Map/mapZoomCeiling.test.ts diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 4d92f63f0..00911a2f1 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -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 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) => ( -
{children}
+ // 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) => ( +
{children}
), TileLayer: () =>
, 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() + 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() diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index fe9737e3a..c119af4a7 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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 diff --git a/client/src/components/Map/mapZoomCeiling.test.ts b/client/src/components/Map/mapZoomCeiling.test.ts new file mode 100644 index 000000000..74664a0cf --- /dev/null +++ b/client/src/components/Map/mapZoomCeiling.test.ts @@ -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) + }) +}) diff --git a/client/src/constants/mapDefaults.ts b/client/src/constants/mapDefaults.ts index fb0a64ac7..d97731181 100644 --- a/client/src/constants/mapDefaults.ts +++ b/client/src/constants/mapDefaults.ts @@ -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}' diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 88fc4b5ab..912fb22ed 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -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' ? ( From 8c85323d6d45310748733b576041f61d155628b7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 15:41:11 +0200 Subject: [PATCH 05/10] 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. --- .../Settings/MapSettingsTab.test.tsx | 28 +++++++++++++++++-- .../components/Settings/MapSettingsTab.tsx | 8 +++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/client/src/components/Settings/MapSettingsTab.test.tsx b/client/src/components/Settings/MapSettingsTab.test.tsx index f1c5c8558..e366cb18f 100644 --- a/client/src/components/Settings/MapSettingsTab.test.tsx +++ b/client/src/components/Settings/MapSettingsTab.test.tsx @@ -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 }) => ( -
onMapClick?.({ latlng: { lat: 51.5, lng: -0.1 } })} /> + MapView: ({ onMapClick, tileUrl }: { onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void; tileUrl?: string }) => ( +
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(); + + 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, { diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 4476508fa..aaa7f74ee 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -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, From 241f1523479469c654eb349b9d3c66fc1cf36610 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 15:47:07 +0200 Subject: [PATCH 06/10] 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. --- client/src/components/Admin/DefaultUserSettingsTab.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index 9b705726b..2f07419ff 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -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, From 0b2cf1c5efb9dfc050c3891068ac00c8823dc390 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 16:06:38 +0200 Subject: [PATCH 07/10] 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. --- client/src/mobile/screens/settings/MSettingsMap.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/mobile/screens/settings/MSettingsMap.tsx b/client/src/mobile/screens/settings/MSettingsMap.tsx index a15de571c..dfc41398e 100644 --- a/client/src/mobile/screens/settings/MSettingsMap.tsx +++ b/client/src/mobile/screens/settings/MSettingsMap.tsx @@ -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, From 43fbee94f98762906f6d1c99f32892b5507f14e9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 16:13:22 +0200 Subject: [PATCH 08/10] 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. --- server/src/nest/calendar/calendar.service.ts | 20 +++++++- .../tests/unit/nest/calendar.service.test.ts | 51 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index fcdf259ef..ee70ced64 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -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; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index d77d5d499..8b15364c5 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -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' }); From 376332d9891aca42fde63f559b59199f97f448e4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 29 Aug 2026 16:36:07 +0200 Subject: [PATCH 09/10] 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. --- .../src/mobile/screens/admin/MAdminDefaultUserSettings.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx index 7b089d00a..86fbb9650 100644 --- a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx +++ b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx @@ -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, From 33a33e7b1d113f0742ac609305cc549a4806d31b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 29 Aug 2026 14:42:16 +0000 Subject: [PATCH 10/10] chore: bump version to 4.1.1 [skip ci] --- charts/trek/Chart.yaml | 4 ++-- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 330a62642..a0d958f11 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -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" diff --git a/client/package.json b/client/package.json index 450df0131..37c54687d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@trek/client", - "version": "4.1.0", + "version": "4.1.1", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 3a71f5ba4..e1f8ad585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index a2a5eb5e2..411c7b017 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@trek/root", "private": true, - "version": "4.1.0", + "version": "4.1.1", "workspaces": [ "client", "server", diff --git a/server/package.json b/server/package.json index e8d060847..5ecd0a230 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/shared/package.json b/shared/package.json index f639c12ef..d75d44ca1 100644 --- a/shared/package.json +++ b/shared/package.json @@ -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",