mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33a33e7b1d | |||
| 376332d989 | |||
| 43fbee94f9 | |||
| 0b2cf1c5ef | |||
| 241f152347 | |||
| 8c85323d6d | |||
| 8635bec507 | |||
| 57637cb447 | |||
| df9e2d542a |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "4.1.0",
|
||||
"version": "4.1.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user