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:
Maurice
2026-09-06 00:36:53 +02:00
committed by Maurice
parent 715993feaf
commit 87a765b763
10 changed files with 710 additions and 2 deletions
+31
View File
@@ -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(
+28
View File
@@ -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)}`, {});