mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(offline): cache the trip's area so place search works with no network
An offline instance had the trip's own places and nothing else, so "find a pharmacy near the hotel" had nothing to answer from: every lookup went to a server. The index can hand over a bounding box in one request, so it does, once, next to the map tiles. A trip to Rostock is roughly a megabyte, refreshed only when the trip's area actually moves. Search and autocomplete fall back to it at the API layer, which is why all ten call sites got the offline path without any of them learning about it, and the answer says `source: 'offline-cache'` rather than pretending to be live. Country packages stay what they were: a SQLite file for anyone who would rather run their own copy of the service. A browser cannot query one without shipping a WASM SQLite, and Germany alone would not fit in IndexedDB, so that door stays where it belongs. /v1/packages lists them, because "you can leave" only means something if the door is visible.
This commit is contained in:
@@ -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<T>(
|
||||
query: string,
|
||||
shape: (places: Record<string, unknown>[]) => T,
|
||||
online: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const fromCache = async (): Promise<Record<string, unknown>[]> => {
|
||||
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<string, unknown>[]
|
||||
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 }),
|
||||
}
|
||||
|
||||
@@ -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<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
importFiles!: Table<ImportSourceFile, [string, string]>;
|
||||
areaPlaces!: Table<CachedAreaPlace, string>;
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<number> {
|
||||
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<CachedAreaPlace[]> {
|
||||
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<string, unknown> {
|
||||
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<void> {
|
||||
await offlineDb.areaPlaces.where('tripId').equals(tripId).delete()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>[]; 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');
|
||||
}
|
||||
|
||||
@@ -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<TrekPlacesArea> {
|
||||
const body = await getJson<TrekPlacesArea>('/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<TrekPlace | null> {
|
||||
try {
|
||||
const body = await getJson<{ place: TrekPlace }>(`/v1/place/${encodeURIComponent(gers)}`, {});
|
||||
|
||||
@@ -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<unknown> => ({ results: [], truncated: false, count: 0 })),
|
||||
}));
|
||||
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user