diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 5e0e10c0c..88da1f213 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,3 +1,4 @@ +import { isEffectivelyOffline } from '../sync/networkMode' import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' import type { Place } from '../types' @@ -1038,10 +1039,58 @@ export const memoriesApi = { }).then(r => r.data), } +/** + * Place search that still answers with no network. + * + * The cache is what `sync/placePrefetcher` stored for the trip's area, and this + * is the one place it is read from, so all ten call sites got the offline path + * without any of them learning about it. `source` says 'offline-cache' rather + * than pretending to be the index — several screens show it, and a stale answer + * that claims to be live is worse than one that says what it is. + * + * Only network-level failures fall through. A 4xx or 5xx means the server did + * answer and the caller has to see it. + */ +async function withCachedPlaces( + query: string, + shape: (places: Record[]) => T, + online: () => Promise, +): Promise { + const fromCache = async (): Promise[]> => { + const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher') + return (await searchCachedPlaces(query)).map(cachedToPlaceRecord) + } + + if (isEffectivelyOffline()) return shape(await fromCache()) + try { + return await online() + } catch (err) { + const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null + const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED' + if (!neverArrived) throw err + const cached = await fromCache() + if (!cached.length) throw err + return shape(cached) + } +} + export const mapsApi = { - search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), + search: (query: string, lang?: string) => + withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () => + apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))), autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) => - apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')), + withCachedPlaces( + input, + (places) => ({ + suggestions: places.map((p) => ({ + placeId: String(p.osm_id), + mainText: String(p.name), + secondaryText: String(p.address || ''), + })), + source: 'offline-cache', + }), + () => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')), + ), details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')), // Pictures and a description for a place that is being looked at but not yet // saved. Fans out to several providers server-side, so it takes a signal and @@ -1062,6 +1111,26 @@ export const mapsApi = { // OSM-only POI explore: places of a category within the current map viewport bbox. // Overpass can be slow on a fresh (uncached) area, so this call gets a longer // timeout than the global default instead of aborting at 8s and showing nothing. + /** + * Every place in a box, for the offline cache. One call per trip area, not + * per keystroke, so the timeout is generous where the search ones are short. + */ + area: ( + bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number }, + limit?: number, + signal?: AbortSignal, + ) => + apiClient + .get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 }) + .then( + (r) => + r.data as { + results: Record[] + truncated: boolean + unavailable?: boolean + }, + ), + pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) => apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }), } diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index a072bf761..fee94142d 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -68,6 +68,14 @@ export interface SyncMeta { tilesBbox: [number, number, number, number] | null; /** Non-photo files available offline for this trip after the last sync. */ filesCachedCount: number; + /** + * The rounded bbox the cached area places were fetched for. Compared rather + * than a timestamp: adding a place inside the area the trip already covers + * should not re-download it, and moving the trip should. + * + * Optional so a row written before this landed still reads. + */ + areaPlacesKey?: string; } export interface BlobCacheEntry { @@ -86,6 +94,33 @@ export interface BlobCacheEntry { cachedAt: number; } +/** + * A place from the TREK Places index, cached for the area of one trip. + * + * NOT a trip place — those are in `places` and belong to the user. These are + * candidates: the shops, restaurants and stations that happen to be near where + * the trip goes, taken once so search still answers on a plane. They are + * disposable and are re-fetched whenever the trip's area changes. + * + * The key is the GERS id, which is stable across index rebuilds; `tripId` is + * indexed so the whole set can be dropped with the trip. + */ +export interface CachedAreaPlace { + /** GERS id, without the `gers:` prefix the server puts on `osm_id`. */ + gers: string; + tripId: number; + name: string; + /** Lowercased, diacritics folded — what an offline search actually matches on. */ + searchName: string; + address: string; + lat: number | null; + lng: number | null; + category: string | null; + website: string | null; + phone: string | null; + cachedAt: number; +} + /** An uploaded booking-import source file, kept so the review flow can attach it to the * created bookings even after a page reload during the (background) parse. Keyed by job. */ export interface ImportSourceFile { @@ -141,6 +176,7 @@ class TrekOfflineDb extends Dexie { syncMeta!: Table; blobCache!: Table; importFiles!: Table; + areaPlaces!: Table; constructor(name: string = ANON_DB_NAME) { super(name); @@ -181,6 +217,13 @@ class TrekOfflineDb extends Dexie { this.version(4).stores({ importFiles: '[jobId+fileName], jobId, createdAt', }); + + // v5: places from the TREK Places index for the trip's area, so search + // answers offline. `searchName` is indexed because that is what an offline + // query filters on; `tripId` so the set drops with its trip. + this.version(5).stores({ + areaPlaces: 'gers, tripId, searchName', + }); } } diff --git a/client/src/sync/placePrefetcher.test.ts b/client/src/sync/placePrefetcher.test.ts new file mode 100644 index 000000000..3e104f126 --- /dev/null +++ b/client/src/sync/placePrefetcher.test.ts @@ -0,0 +1,180 @@ +/** + * FE-PLACEPRE-001..012 — the places around a trip, kept for offline search. + * + * The gap this closes: an offline instance had the trip's own places and + * nothing else, so "find a pharmacy near the hotel" had nothing to answer from. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import 'fake-indexeddb/auto' +import { offlineDb, clearAll } from '../db/offlineDb' +import { + AREA_PLACE_LIMIT, + cachedToPlaceRecord, + clearCachedPlaces, + foldName, + prefetchPlacesForTrip, + searchCachedPlaces, +} from './placePrefetcher' +import { mapsApi } from '../api/client' +import type { Place } from '../types' + +vi.mock('../api/client', () => ({ mapsApi: { area: vi.fn() } })) +const areaMock = vi.mocked(mapsApi.area) + +const place = (lat: number, lng: number): Place => + ({ id: 1, trip_id: 7, name: 'Hotel', lat, lng }) as unknown as Place + +const TRIP_PLACES = [place(54.08, 12.13), place(54.1, 12.16)] + +function apiRow(name: string, over: Record = {}) { + return { + osm_id: `gers:${name.toLowerCase().replace(/\W/g, '')}`, + name, + address: 'Steinstrasse 9, 18055, Rostock', + lat: 54.09, + lng: 12.14, + category: 'restaurant', + website: null, + phone: null, + ...over, + } +} + +beforeEach(async () => { + await clearAll() + areaMock.mockReset() + areaMock.mockResolvedValue({ results: [apiRow("L'Osteria"), apiRow('Café Central')], truncated: false }) + await offlineDb.syncMeta.put({ + tripId: 7, + lastSyncedAt: null, + status: 'idle', + tilesBbox: null, + filesCachedCount: 0, + }) +}) + +afterEach(() => vi.restoreAllMocks()) + +describe('foldName', () => { + it('FE-PLACEPRE-001: folds diacritics and case, so "cafe" finds "Café"', () => { + expect(foldName('Café Central')).toBe('cafe central') + expect(foldName(' Zürich ')).toBe('zurich') + }) +}) + +describe('prefetchPlacesForTrip', () => { + it('FE-PLACEPRE-002: stores the area under the trip', async () => { + const n = await prefetchPlacesForTrip(7, TRIP_PLACES) + expect(n).toBe(2) + const rows = await offlineDb.areaPlaces.toArray() + expect(rows.map((r) => r.name).sort()).toEqual(['Café Central', "L'Osteria"]) + expect(rows.every((r) => r.tripId === 7)).toBe(true) + expect(rows[0].gers.startsWith('gers:')).toBe(false) + }) + + it('FE-PLACEPRE-003: asks for a box around the trip, within the index cap', async () => { + await prefetchPlacesForTrip(7, TRIP_PLACES) + const [bbox, limit] = areaMock.mock.calls[0] + expect(bbox.minLat).toBeLessThan(54.08) + expect(bbox.maxLat).toBeGreaterThan(54.1) + expect(bbox.maxLat - bbox.minLat).toBeLessThanOrEqual(1.5) + expect(limit).toBe(AREA_PLACE_LIMIT) + }) + + it('FE-PLACEPRE-004: does not re-download an area that has not moved', async () => { + await prefetchPlacesForTrip(7, TRIP_PLACES) + const again = await prefetchPlacesForTrip(7, TRIP_PLACES) + expect(again).toBe(0) + expect(areaMock).toHaveBeenCalledTimes(1) + }) + + it('FE-PLACEPRE-005: re-downloads when the trip moves', async () => { + await prefetchPlacesForTrip(7, TRIP_PLACES) + await prefetchPlacesForTrip(7, [place(41.9, 12.5), place(41.92, 12.52)]) + expect(areaMock).toHaveBeenCalledTimes(2) + }) + + it('FE-PLACEPRE-006: replaces the old area rather than merging into it', async () => { + await prefetchPlacesForTrip(7, TRIP_PLACES) + areaMock.mockResolvedValue({ results: [apiRow('Trattoria Roma')], truncated: false }) + await prefetchPlacesForTrip(7, [place(41.9, 12.5), place(41.92, 12.52)]) + // Keeping the old rows would make an offline search answer with the last city. + expect((await offlineDb.areaPlaces.toArray()).map((r) => r.name)).toEqual(['Trattoria Roma']) + }) + + it('FE-PLACEPRE-007: a trip with no located places costs nothing', async () => { + expect(await prefetchPlacesForTrip(7, [])).toBe(0) + expect(areaMock).not.toHaveBeenCalled() + }) + + it('FE-PLACEPRE-008: clamps a trip that spans more than the index allows', async () => { + await prefetchPlacesForTrip(7, [place(35, 5), place(60, 25)]) + const [bbox] = areaMock.mock.calls[0] + expect(bbox.maxLat - bbox.minLat).toBeLessThanOrEqual(1.5) + expect(bbox.maxLng - bbox.minLng).toBeLessThanOrEqual(1.5) + }) + + it('FE-PLACEPRE-009: a failure never propagates into the sync', async () => { + areaMock.mockRejectedValue(new Error('service down')) + await expect(prefetchPlacesForTrip(7, TRIP_PLACES)).resolves.toBe(0) + }) + + it('FE-PLACEPRE-010: stores nothing when the index is switched off', async () => { + areaMock.mockResolvedValue({ results: [], truncated: false, unavailable: true }) + expect(await prefetchPlacesForTrip(7, TRIP_PLACES)).toBe(0) + expect(await offlineDb.areaPlaces.count()).toBe(0) + }) + + it('FE-PLACEPRE-011: skips a row with no id or no name rather than storing a blank', async () => { + areaMock.mockResolvedValue({ + results: [apiRow('Real Place'), { ...apiRow('x'), name: ' ' }, { ...apiRow('y'), osm_id: null }], + truncated: false, + }) + expect(await prefetchPlacesForTrip(7, TRIP_PLACES)).toBe(1) + }) +}) + +describe('searchCachedPlaces', () => { + beforeEach(async () => { + await prefetchPlacesForTrip(7, TRIP_PLACES) + }) + + it('FE-PLACEPRE-012: matches on a prefix and on a word inside the name', async () => { + expect((await searchCachedPlaces('cafe')).map((p) => p.name)).toEqual(['Café Central']) + // No prefix index answers this one — "L'Osteria" folds to "l'osteria". + expect((await searchCachedPlaces('osteria')).map((p) => p.name)).toEqual(["L'Osteria"]) + }) + + it('FE-PLACEPRE-013: ignores a query too short to mean anything', async () => { + expect(await searchCachedPlaces('c')).toEqual([]) + }) + + it('FE-PLACEPRE-014: reads across trips, because the area is where you are', async () => { + // tripId is how a set is evicted, not who may read it. Somebody offline in + // Rostock searching from the journal wants the same places. + expect((await searchCachedPlaces('osteria')).length).toBe(1) + await offlineDb.areaPlaces.toCollection().modify({ tripId: 99 }) + expect((await searchCachedPlaces('osteria')).length).toBe(1) + }) + + it('FE-PLACEPRE-015: hands back the shape every search caller already reads', async () => { + const [hit] = await searchCachedPlaces('osteria') + expect(cachedToPlaceRecord(hit)).toMatchObject({ + osm_id: "gers:l'osteria".replace("'", ''), + name: "L'Osteria", + // Named rather than passed off as live: several screens show this. + source: 'offline-cache', + rating: null, + }) + }) +}) + +describe('clearCachedPlaces', () => { + it('FE-PLACEPRE-016: drops one trip and leaves the others', async () => { + await prefetchPlacesForTrip(7, TRIP_PLACES) + await offlineDb.areaPlaces.toCollection().modify({ tripId: 8 }) + await prefetchPlacesForTrip(7, TRIP_PLACES, true) + await clearCachedPlaces(7) + expect((await offlineDb.areaPlaces.toArray()).every((r) => r.tripId === 8)).toBe(true) + }) +}) diff --git a/client/src/sync/placePrefetcher.ts b/client/src/sync/placePrefetcher.ts new file mode 100644 index 000000000..943d92131 --- /dev/null +++ b/client/src/sync/placePrefetcher.ts @@ -0,0 +1,199 @@ +/** + * Takes the places around a trip and keeps them, so search works with no network. + * + * The gap this closes: an offline instance has the trip's own places and + * nothing else. "Find a pharmacy near the hotel" had nothing to answer from, + * because every place lookup went to a server. The TREK Places index can hand + * over an area in one request, so it does, once, next to the map tiles. + * + * Deliberately modest. This is not a country download and not a background + * sweep: one box around the trip, a few thousand rows, refreshed only when the + * trip's area actually changes. A trip to Rostock costs roughly a megabyte. + */ +import { mapsApi } from '../api/client' +import { offlineDb, type CachedAreaPlace } from '../db/offlineDb' +import type { Place } from '../types' +import { computeBbox } from './tilePrefetcher' + +/** + * Enough to answer "what is around here" for a city, small enough that the + * write is a blink. The index orders by confidence, so a trimmed area keeps the + * places most likely to be real rather than an arbitrary slice. + */ +export const AREA_PLACE_LIMIT = 3000 + +/** Padding around the trip's own places. Wider than the tile bbox: somebody + * searching from their hotel is looking at the next street, not the next pin. */ +const AREA_PADDING = 0.25 + +/** The index's own cap. A trip spanning more than this gets its centre, not a refusal. */ +const MAX_SPAN_DEG = 1.5 + +/** + * Fold a name down to what an offline query can match. + * + * NFD then strip the combining marks: "Café" and "Cafe" are the same word to + * somebody typing on a phone keyboard, and an offline search that disagrees is + * just broken. Same normalisation on both sides, applied at write time so the + * query does not pay for it per row. + */ +export function foldName(name: string): string { + return name + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .trim() +} + +/** Shrink an oversized box towards its centre rather than giving up on it. */ +function clampSpan(bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number }) { + const midLat = (bbox.minLat + bbox.maxLat) / 2 + const midLng = (bbox.minLng + bbox.maxLng) / 2 + const half = MAX_SPAN_DEG / 2 + return { + minLat: Math.max(bbox.minLat, midLat - half), + maxLat: Math.min(bbox.maxLat, midLat + half), + minLng: Math.max(bbox.minLng, midLng - half), + maxLng: Math.min(bbox.maxLng, midLng + half), + } +} + +function bboxKey(b: { minLat: number; minLng: number; maxLat: number; maxLng: number }): string { + // Three decimals is about 100 m — finer than that and adding one place to a + // trip would re-download the area for no new coverage. + return [b.minLat, b.minLng, b.maxLat, b.maxLng].map((n) => n.toFixed(3)).join(',') +} + +interface AreaRecord { + osm_id?: string | null + name?: string | null + address?: string | null + lat?: number | null + lng?: number | null + category?: string | null + website?: string | null + phone?: string | null +} + +function toCached(tripId: number, r: AreaRecord, now: number): CachedAreaPlace | null { + const id = typeof r.osm_id === 'string' ? r.osm_id.replace(/^gers:/, '') : '' + const name = typeof r.name === 'string' ? r.name.trim() : '' + if (!id || !name) return null + return { + gers: id, + tripId, + name, + searchName: foldName(name), + address: typeof r.address === 'string' ? r.address : '', + lat: typeof r.lat === 'number' ? r.lat : null, + lng: typeof r.lng === 'number' ? r.lng : null, + category: r.category ?? null, + website: r.website ?? null, + phone: r.phone ?? null, + cachedAt: now, + } +} + +/** + * Cache the area around a trip. Safe to call on every sync. + * + * Returns the number of places stored, or 0 when there was nothing to do — the + * trip has no located places, the area is unchanged since last time, or the + * index is switched off. Never throws: this runs inside a sync, and a place + * cache that could not be filled must not cost the user their trip data. + */ +export async function prefetchPlacesForTrip( + tripId: number, + places: Place[], + force = false, +): Promise { + const raw = computeBbox(places, AREA_PADDING) + if (!raw) return 0 + const bbox = clampSpan(raw) + + const key = bboxKey(bbox) + const meta = await offlineDb.syncMeta.get(tripId) + const stored = meta?.areaPlacesKey + if (!force && stored === key) return 0 + + try { + const area = await mapsApi.area(bbox, AREA_PLACE_LIMIT) + if (area.unavailable) return 0 + + const now = Date.now() + const rows = (area.results || []) + .map((r) => toCached(tripId, r as AreaRecord, now)) + .filter((r): r is CachedAreaPlace => r !== null) + + // Replace rather than merge: the area moved, and rows from the old one are + // places the trip is no longer near. Keeping them would make an offline + // search answer with the last city. + await offlineDb.transaction('rw', offlineDb.areaPlaces, async () => { + await offlineDb.areaPlaces.where('tripId').equals(tripId).delete() + if (rows.length) await offlineDb.areaPlaces.bulkPut(rows) + }) + + if (meta) await offlineDb.syncMeta.put({ ...meta, areaPlacesKey: key }) + if (rows.length) { + console.info( + `[placePrefetch] trip ${tripId}: cached ${rows.length} places${area.truncated ? ' (area trimmed)' : ''}`, + ) + } + return rows.length + } catch (err) { + console.warn('[placePrefetch] skipped:', (err as Error).message) + return 0 + } +} + +/** + * Offline place search over everything cached for any trip. + * + * Not scoped to a trip on purpose: the `tripId` on a row is how the set is + * evicted, not who may read it. Somebody offline in Rostock searching from the + * journal, a collection or the planner is looking for the same places, and + * making three of those four screens answer "no connection" because the area + * was fetched under a different trip id would be an arbitrary refusal. + * + * Prefix-and-substring on the folded name. Not a ranking engine: it is the + * difference between a search box that answers and one that does not. + */ +export async function searchCachedPlaces(query: string, limit = 10): Promise { + const needle = foldName(query) + if (needle.length < 2) return [] + + // Prefix hits come off the index; the substring pass is the fallback for + // "osteria" matching "L'Osteria Rostock", which no prefix index can answer. + const byPrefix = await offlineDb.areaPlaces.where('searchName').startsWith(needle).limit(limit).toArray() + if (byPrefix.length >= limit) return byPrefix + + const seen = new Set(byPrefix.map((p) => p.gers)) + const rest = await offlineDb.areaPlaces + .filter((p) => !seen.has(p.gers) && p.searchName.includes(needle)) + .limit(limit - byPrefix.length) + .toArray() + + return [...byPrefix, ...rest] +} + +/** A cached row in the shape every search caller already reads. */ +export function cachedToPlaceRecord(p: CachedAreaPlace): Record { + return { + google_place_id: null, + osm_id: `gers:${p.gers}`, + name: p.name, + address: p.address, + lat: p.lat, + lng: p.lng, + rating: null, + website: p.website, + phone: p.phone, + category: p.category, + source: 'offline-cache', + } +} + +/** Drop a trip's cached area. Called when the trip leaves the offline set. */ +export async function clearCachedPlaces(tripId: number): Promise { + await offlineDb.areaPlaces.where('tripId').equals(tripId).delete() +} diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 9324cc4d6..d2a4c5108 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -29,6 +29,7 @@ import { clearTripData, enforceBlobBudget, } from '../db/offlineDb' +import { prefetchPlacesForTrip } from './placePrefetcher' import { prefetchTilesForTrip } from './tilePrefetcher' import { isAuthed } from './authGate' import { getOfflinePrefs, isTripOfflineEnabled, isTripPinned } from './offlinePrefs' @@ -264,6 +265,10 @@ export const tripSyncManager = { if (!isAuthed() || !navigator.onLine) return const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() await prefetchTilesForTrip(trip.id, places, tileUrl, undefined, cartoKey).catch(console.error) + // The places around the trip, so search answers with no network. + // Sequential rather than parallel: the tiles are the bigger win and + // should not be competing with this for the connection. + await prefetchPlacesForTrip(trip.id, places).catch(console.error) } }) } @@ -328,6 +333,7 @@ export const tripSyncManager = { onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title }) const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() await prefetchTilesForTrip(trip.id, places, tileUrl, true, cartoKey).catch(console.error) + await prefetchPlacesForTrip(trip.id, places, true).catch(console.error) } } diff --git a/server/src/nest/maps/maps.controller.ts b/server/src/nest/maps/maps.controller.ts index 644889fb0..c17b240b7 100644 --- a/server/src/nest/maps/maps.controller.ts +++ b/server/src/nest/maps/maps.controller.ts @@ -105,6 +105,37 @@ export class MapsController { } } + /** + * Every place in a box, for the offline cache. Read-only and cheap. + * + * Not a browse endpoint: the index caps the box at 1.5 degrees a side, and + * this passes the cap's refusal straight through rather than paging around + * it. The honest use is one trip's area, taken once. + */ + @Get('area') + async area( + @Query('minLat') minLat?: string, + @Query('minLng') minLng?: string, + @Query('maxLat') maxLat?: string, + @Query('maxLng') maxLng?: string, + @Query('limit') limit?: string, + ) { + const bbox = { + minLat: Number(minLat), + minLng: Number(minLng), + maxLat: Number(maxLat), + maxLng: Number(maxLng), + }; + if (Object.values(bbox).some((v) => !Number.isFinite(v))) { + throw new HttpException({ error: 'A valid bbox (minLat, minLng, maxLat, maxLng) is required' }, 400); + } + const capped = Math.min(Math.max(Number(limit) || 2000, 1), 5000); + const area = await this.maps.placesInArea(bbox, capped); + // A null means the index is off or unreachable. An empty area is not an + // error — the caller caches nothing and carries on. + return area ?? { results: [], truncated: false, unavailable: true }; + } + @Post('autocomplete') @HttpCode(200) async autocomplete( diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index c0c9916a9..61580c363 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -18,6 +18,7 @@ import { nominatimFetch, type GeoLane } from '../geo/nominatim.client'; import { trekPlacesSearch, trekPlacesById, + trekPlacesArea, trekPlacesNearby, toPlaceRecord, POI_CATEGORY_TO_TREK, @@ -559,6 +560,33 @@ export class MapsService { return row?.value !== 'false'; } + /** + * The places in a trip's area, for the offline cache. + * + * The one call TREK makes that is not driven by something a user just typed. + * It runs when a trip is prepared for offline use, alongside the map tiles, + * and it is the reason searching a trip works on a plane: without it the + * offline instance has the trip's own places and nothing else, so "find a + * pharmacy near the hotel" has nothing to answer from. + * + * Returns null rather than throwing when the index is switched off or the + * service is unreachable, because this is a nice-to-have running in the + * background of a sync — a failure here must not fail the sync. + */ + async placesInArea( + bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number }, + limit?: number, + ): Promise<{ results: Record[]; truncated: boolean } | null> { + if (!this.trekPlacesEnabled()) return null; + try { + const area = await trekPlacesArea(bbox, limit); + return { results: area.results.map(toPlaceRecord), truncated: area.truncated }; + } catch (err) { + console.warn('TREK Places area lookup failed:', (err as Error).message); + return null; + } + } + autocompleteDisabled(): boolean { return this.isSettingDisabled('places_autocomplete_enabled'); } diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts index d9aa21b0e..137398dab 100644 --- a/server/src/nest/maps/trek-places.client.ts +++ b/server/src/nest/maps/trek-places.client.ts @@ -185,6 +185,33 @@ export async function trekPlacesNearby( return Array.isArray(body.results) ? body.results : []; } +export interface TrekPlacesArea { + count: number; + truncated: boolean; + results: TrekPlace[]; +} + +/** + * Every place in a box, best first. What an instance takes once so the trip + * keeps working with no network. + * + * The API caps the box at 1.5 degrees a side and the row count at 20000; both + * are its call, not ours, and a `truncated` answer is passed through honestly + * rather than smoothed over. A client that believed it had the whole area would + * search offline and quietly miss places. + */ +export async function trekPlacesArea( + bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number }, + limit = 2000, +): Promise { + const body = await getJson('/v1/bbox', { ...bbox, limit }); + return { + count: Number(body.count) || 0, + truncated: !!body.truncated, + results: Array.isArray(body.results) ? body.results : [], + }; +} + export async function trekPlacesById(gers: string): Promise { try { const body = await getJson<{ place: TrekPlace }>(`/v1/place/${encodeURIComponent(gers)}`, {}); diff --git a/server/tests/unit/nest/maps.area.test.ts b/server/tests/unit/nest/maps.area.test.ts new file mode 100644 index 000000000..9facbc15e --- /dev/null +++ b/server/tests/unit/nest/maps.area.test.ts @@ -0,0 +1,91 @@ +/** + * MAPS-AREA-001..006 — the trip's area, handed to the client for offline use. + * + * The one maps call that is not driven by something a user just typed: it runs + * inside an offline sync, which is why every failure path here ends in null + * rather than an exception. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockArea } = vi.hoisted(() => ({ + mockArea: vi.fn(async (): Promise => ({ results: [], truncated: false, count: 0 })), +})); +vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({ + ...(await importOriginal()), + trekPlacesArea: mockArea, +})); + +vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); + +import { MapsService } from '../../../src/nest/maps/maps.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; +import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; + +const BOX = { minLat: 54, minLng: 12, maxLat: 54.2, maxLng: 12.3 }; + +const PLACE = { + gers: 'abc-123', + name: "L'Osteria", + lat: 54.0879, + lng: 12.1408, + category: 'restaurant', + categoryPath: 'eat_and_drink>restaurant', + confidence: 1, + address: { freeform: 'Steinstrasse 9', locality: 'Rostock', postcode: '18055', region: null, country: 'DE' }, + contact: { website: 'https://losteria.net/', phone: null, email: null, socials: null }, + brand: null, + source: 'overture', + hours: null, +}; + +function make(enabled = true) { + const database = { + get: vi.fn(() => (enabled ? undefined : { value: 'false' })), + } as unknown as DatabaseService; + return new MapsService(database, {} as PlacePhotoCacheService); +} + +beforeEach(() => { + mockArea.mockReset(); + mockArea.mockResolvedValue({ results: [PLACE], truncated: false, count: 1 }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +describe('MapsService.placesInArea', () => { + it('MAPS-AREA-001: hands back records in the shape the client already reads', async () => { + const out = await make().placesInArea(BOX, 500); + expect(mockArea).toHaveBeenCalledWith(BOX, 500); + expect(out?.results[0]).toMatchObject({ + osm_id: 'gers:abc-123', + name: "L'Osteria", + source: 'trek-places', + }); + }); + + it('MAPS-AREA-002: passes truncation through instead of smoothing it over', async () => { + // A client that believed it had the whole area would search offline and + // quietly miss places. + mockArea.mockResolvedValue({ results: [PLACE], truncated: true, count: 1 }); + expect((await make().placesInArea(BOX))?.truncated).toBe(true); + }); + + it('MAPS-AREA-003: returns null when the admin switched the index off', async () => { + expect(await make(false).placesInArea(BOX)).toBeNull(); + expect(mockArea).not.toHaveBeenCalled(); + }); + + it('MAPS-AREA-004: a service failure is a null, not a thrown sync', async () => { + mockArea.mockRejectedValue(new Error('places api down')); + await expect(make().placesInArea(BOX)).resolves.toBeNull(); + }); + + it('MAPS-AREA-005: an empty area is an answer, not a failure', async () => { + mockArea.mockResolvedValue({ results: [], truncated: false, count: 0 }); + expect(await make().placesInArea(BOX)).toEqual({ results: [], truncated: false }); + }); + + it('MAPS-AREA-006: leaves the limit to the caller when none is given', async () => { + await make().placesInArea(BOX); + expect(mockArea).toHaveBeenCalledWith(BOX, undefined); + }); +}); diff --git a/server/tests/unit/nest/maps.controller.test.ts b/server/tests/unit/nest/maps.controller.test.ts index 9d245c93b..1567b4ebe 100644 --- a/server/tests/unit/nest/maps.controller.test.ts +++ b/server/tests/unit/nest/maps.controller.test.ts @@ -74,6 +74,40 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { }); }); + describe('GET /area', () => { + const BOX = { minLat: 54, minLng: 12, maxLat: 54.2, maxLng: 12.3 }; + + it('delegates a valid box with parsed numbers', async () => { + const placesInArea = vi.fn().mockResolvedValue({ results: [{ name: 'x' }], truncated: false }); + const res = await makeController({ placesInArea }).area('54', '12', '54.2', '12.3'); + expect(res).toEqual({ results: [{ name: 'x' }], truncated: false }); + expect(placesInArea).toHaveBeenCalledWith(BOX, 2000); + }); + + it('400 when the box has a non-finite value', async () => { + const placesInArea = vi.fn(); + expect(await thrown(() => makeController({ placesInArea }).area('x', '12', '54.2', '12.3'))).toEqual({ + status: 400, + body: { error: 'A valid bbox (minLat, minLng, maxLat, maxLng) is required' }, + }); + expect(placesInArea).not.toHaveBeenCalled(); + }); + + it('caps the limit rather than passing a caller number straight through', async () => { + const placesInArea = vi.fn().mockResolvedValue({ results: [], truncated: false }); + await makeController({ placesInArea }).area('54', '12', '54.2', '12.3', '999999'); + expect(placesInArea).toHaveBeenCalledWith(BOX, 5000); + }); + + it('says the index is unavailable instead of failing the caller', async () => { + // This runs inside an offline sync. A null here means the index is off or + // unreachable, and the client caches nothing and carries on. + const placesInArea = vi.fn().mockResolvedValue(null); + const res = await makeController({ placesInArea }).area('54', '12', '54.2', '12.3'); + expect(res).toEqual({ results: [], truncated: false, unavailable: true }); + }); + }); + describe('GET /pois', () => { it('400 when category is missing', async () => { const pois = vi.fn();