mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user