fix(maps): stop asking an index that is not answering, and untie the place cache

Three things an install feels rather than reads.

A service that cannot be reached cost the full timeout on every call. Each call
site falls back to the old path, but pays 3.5 s first — so an instance behind a
firewall that drops rather than refuses, or one that simply has no route to the
service, pays it on every keystroke of autocomplete and on all three queries a
booking import runs per venue. Ten venues is over a hundred seconds of waiting
for an answer that was never coming. A process-wide breaker now fails instantly
after four consecutive failures and tries again a minute later, so an
unreachable index costs one timeout instead of one per request. A 404 does not
count against it, because "no such place" is an answer, and neither does an
oversized body, because the service replied.

The offline place cache hung off the map-tile switch. That setting is about
tiles — its own text says "off keeps the cache to trip data + documents only" —
and it gets turned off for space, tens of megabytes of it. A trip's places are
about one. Somebody who turned tiles off therefore got an empty offline search
with no explanation, and "prepare for offline" reported success having stored no
place at all. The two run independently now; the tile progress phase stays gated
on the tile setting, so a run without tiles still reports no tile phase.

Tests for the merged search path, which had none: the wiring around
mergeSearchResults was uncovered, so which list goes in first and what `source`
says were both free to change. Neither is cosmetic — index-first is the whole
point of the feature, and the search log writes `source` into the corpus a
candidate index gets scored against later. Two of the eight cases pin the fix
from the previous commit: an index failure keeps the OpenStreetMap answer, and
neither source is asked twice.

place-shadow lands on its own coverage entry rather than the 80 catch-all, where
a new domain could shed twenty points unnoticed. Pinned under its measured
figure on purpose; the exact ratchet gets regenerated with the repo's own script
after a full run.
This commit is contained in:
Maurice
2026-09-07 11:35:10 +02:00
committed by Maurice
parent 48f0f29273
commit b0aa7f8d95
6 changed files with 385 additions and 16 deletions
@@ -9,6 +9,7 @@ import { tripSyncManager, type PrepareProgress } from './tripSyncManager'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { setAuthed } from './authGate'
import { setCacheTiles, setTripOfflineEnabled, _resetOfflinePrefs } from './offlinePrefs'
import { prefetchPlacesForTrip } from './placePrefetcher'
import { offlineDb, clearAll, upsertTrip } from '../db/offlineDb'
import { buildTrip, buildDay, buildPlace, buildTripFile } from '../../tests/helpers/factories'
import type { Trip, TripFile } from '../types'
@@ -16,8 +17,12 @@ import type { Trip, TripFile } from '../types'
vi.mock('./tilePrefetcher', () => ({
prefetchTilesForTrip: vi.fn(async () => {}),
}))
vi.mock('./placePrefetcher', () => ({
prefetchPlacesForTrip: vi.fn(async () => 0),
}))
const prefetchMock = vi.mocked(prefetchTilesForTrip)
const placesMock = vi.mocked(prefetchPlacesForTrip)
function dateOffset(days: number): string {
const d = new Date()
@@ -62,6 +67,8 @@ beforeEach(async () => {
setOnline(true)
prefetchMock.mockClear()
prefetchMock.mockResolvedValue(undefined)
placesMock.mockClear()
placesMock.mockResolvedValue(0)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
blob: async () => new Blob(['pdf-bytes'], { type: 'application/pdf' }),
@@ -158,6 +165,13 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
expect(progress.map(p => p.phase)).toEqual(['trips', 'files', 'done'])
expect(prefetchMock).not.toHaveBeenCalled()
// The places still come down. That switch is about tens of megabytes of
// imagery; a trip's places are about one, and hanging them off it meant
// somebody who turned it off for space got an empty offline search with no
// explanation, while this very run reported success.
expect(placesMock).toHaveBeenCalledTimes(1)
expect(placesMock.mock.calls[0][0]).toBe(604)
expect(placesMock.mock.calls[0][2]).toBe(true)
})
it('FE-SYNC-PREP-008: caches the global tags and categories', async () => {
+30 -15
View File
@@ -268,19 +268,25 @@ export const tripSyncManager = {
// after login, where the app is still mounting the first screen — starting
// a bulk tile download into that leaves the UI waiting behind our own
// background traffic.
if (cacheTiles) {
whenIdle(async () => {
for (const trip of toSync) {
if (!isAuthed() || !navigator.onLine) return
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
// The place cache is not tied to the tile setting. That switch is about
// map tiles — "off keeps the cache to trip data + documents only" — and it
// is turned off for space, tens of megabytes of them. A trip's places are
// about a megabyte, and hanging them off that switch meant somebody who
// turned it off got an empty offline search with no explanation, while
// "prepare for offline" still reported success having stored no place at
// all.
whenIdle(async () => {
for (const trip of toSync) {
if (!isAuthed() || !navigator.onLine) return
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
// Tiles first when they are wanted: they are the bigger win, and the
// two should not compete for the connection.
if (cacheTiles) {
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)
}
})
}
await prefetchPlacesForTrip(trip.id, places).catch(console.error)
}
})
return { status: 'done', trips: toSync.length }
} finally {
_syncing = false
@@ -332,16 +338,25 @@ export const tripSyncManager = {
await cacheFilesForTrip(trip.id, files).catch(console.error)
}
// 3) Map tiles — awaited, and only when the user opted to store them.
if (getOfflinePrefs().cacheTiles) {
// 3) Map tiles — awaited, and only when the user opted to store them. The
// places go with every run: "prepared" has to mean the offline search
// works, and the tile switch is about tens of megabytes of imagery, not
// about the megabyte a trip's places cost.
{
const wantTiles = getOfflinePrefs().cacheTiles
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
i = 0
for (const trip of toSync) {
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
// Reported only when there is a tile phase to report. The place
// prefetch rides along quietly, the way it always did — it is a
// megabyte, not the tens the progress bar exists for.
if (wantTiles) 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)
if (wantTiles) {
await prefetchTilesForTrip(trip.id, places, tileUrl, true, cartoKey).catch(console.error)
}
await prefetchPlacesForTrip(trip.id, places, true).catch(console.error)
}
}