mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
9deedb80ea
Saving an expense twice while its receipt is also attached to a place and a booking hit the new unique index on (file, expense): the row kept from the first save was skipped, the next spare link row was adopted into a duplicate pair, and the constraint threw inside the transaction. The whole edit rolled back with a 500, every time, so that expense could never be changed again. The adopt now skips a link the expense already holds, and a receipt named twice in one save is linked once. The offline place cache was keyed on the place id alone while it is owned and evicted per trip, so two trips over the same city took each other's rows: the second write claimed them, switching the second trip off deleted them for both, and the first trip's fingerprint still matched so nothing ever refilled them. Offline search then answered nothing for a trip whose switch read "on". Each trip keeps its own copies now, and the upgrade clears the fingerprints so the next sync refills what the rebuild dropped. The chat composer minted its object URLs inside a state updater, which React runs twice in development, so one picked image drew two thumbnails and removing one left a preview for a picture that would not be sent. File and URL are one record now and every mutator commits a finished list. import_google_maps_route wrote up to thirty places and their day assignments under trips:write, which does not imply the places:write that create_place is gated on, and it was the one non-admin write tool with no demo gate and the only tool in the repo with no annotations. All three corrected.
247 lines
9.1 KiB
TypeScript
247 lines
9.1 KiB
TypeScript
/**
|
|
* 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 { isAuthed } from './authGate'
|
|
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
|
|
|
|
// Checked after the await, not only before it: the area request has a long
|
|
// timeout, and a logout in the meantime deletes the user's database and
|
|
// repoints the handle at the anonymous one. Writing then would seed one
|
|
// person's trip area into the next person's offline search — the same
|
|
// reason cacheFilesForTrip and the tile prefetch re-check here.
|
|
if (!isAuthed()) 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)
|
|
})
|
|
|
|
// Written whether or not a sync row exists yet. Skipping it when there is
|
|
// none left the fingerprint unwritten for a trip cached before its first
|
|
// sync, so the next run re-downloaded the same area for nothing.
|
|
await offlineDb.syncMeta.put({
|
|
tripId,
|
|
lastSyncedAt: meta?.lastSyncedAt ?? 0,
|
|
status: meta?.status ?? 'idle',
|
|
tilesBbox: meta?.tilesBbox ?? null,
|
|
filesCachedCount: meta?.filesCachedCount ?? 0,
|
|
...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.
|
|
// Deduplicated on the GERS id: a place inside two cached trips' areas is
|
|
// stored once per trip, and a search that spans every trip would otherwise
|
|
// hand the same restaurant back twice.
|
|
const seen = new Set<string>()
|
|
const take = (rows: CachedAreaPlace[]) => {
|
|
const out: CachedAreaPlace[] = []
|
|
for (const row of rows) {
|
|
if (seen.has(row.gers)) continue
|
|
seen.add(row.gers)
|
|
out.push(row)
|
|
if (seen.size >= limit) break
|
|
}
|
|
return out
|
|
}
|
|
|
|
const byPrefix = take(await offlineDb.areaPlaces.where('searchName').startsWith(needle).limit(limit * 2).toArray())
|
|
if (byPrefix.length >= limit) return byPrefix
|
|
|
|
const rest = take(
|
|
await offlineDb.areaPlaces
|
|
.filter((p) => !seen.has(p.gers) && p.searchName.includes(needle))
|
|
.limit((limit - byPrefix.length) * 2)
|
|
.toArray(),
|
|
)
|
|
|
|
return [...byPrefix, ...rest]
|
|
}
|
|
|
|
/** A cached row in the shape every search caller already reads. */
|
|
/**
|
|
* One cached place by the id the suggestion list handed out.
|
|
*
|
|
* Takes the `gers:`-prefixed form the server puts on `osm_id`, because that is
|
|
* what a suggestion carries and what the details lookup is called with. Any
|
|
* other id shape belongs to a source the cache does not hold, and is a miss
|
|
* rather than an error.
|
|
*/
|
|
export async function getCachedPlace(placeId: string): Promise<CachedAreaPlace | null> {
|
|
if (!placeId.startsWith('gers:')) return null
|
|
// Through the `gers` index rather than the primary key: the key is compound
|
|
// now, and which trip cached the row is not something the caller knows or
|
|
// should have to know.
|
|
return (await offlineDb.areaPlaces.where('gers').equals(placeId.slice('gers:'.length)).first()) ?? null
|
|
}
|
|
|
|
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',
|
|
}
|
|
}
|
|
|