fix: receipts, cached places, chat previews and the Google-route tool

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.
This commit is contained in:
Maurice
2026-09-13 22:55:15 +02:00
parent f0e4c9a33d
commit 9deedb80ea
9 changed files with 292 additions and 54 deletions
+31
View File
@@ -92,6 +92,37 @@ describe('prefetchPlacesForTrip', () => {
expect(areaMock).toHaveBeenCalledTimes(1)
})
it('FE-PLACEPRE-005b: two trips over the same city keep their own copies', async () => {
// Keyed on the GERS id alone, the second trip's write rewrote the first
// trip's rows to point at itself, and switching the second trip off then
// deleted them for both.
await offlineDb.syncMeta.put({
tripId: 8, lastSyncedAt: null, status: 'idle', tilesBbox: null, filesCachedCount: 0,
})
await prefetchPlacesForTrip(7, TRIP_PLACES)
await prefetchPlacesForTrip(8, TRIP_PLACES)
const rows = await offlineDb.areaPlaces.toArray()
expect(rows).toHaveLength(4)
expect(rows.filter((r) => r.tripId === 7)).toHaveLength(2)
expect(rows.filter((r) => r.tripId === 8)).toHaveLength(2)
});
it('FE-PLACEPRE-005c: dropping one trip leaves the other trip searchable', async () => {
await offlineDb.syncMeta.put({
tripId: 8, lastSyncedAt: null, status: 'idle', tilesBbox: null, filesCachedCount: 0,
})
await prefetchPlacesForTrip(7, TRIP_PLACES)
await prefetchPlacesForTrip(8, TRIP_PLACES)
await offlineDb.areaPlaces.where('tripId').equals(8).delete()
// Offline search spans every cached trip, and trip 7 never asked to lose
// anything. Its fingerprint also still matches, so nothing would refill it.
const hits = await searchCachedPlaces('osteria')
expect(hits.map((h) => h.name)).toEqual(["L'Osteria"])
});
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)])
+26 -7
View File
@@ -183,14 +183,30 @@ export async function searchCachedPlaces(query: string, limit = 10): Promise<Cac
// 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()
// 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 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()
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]
}
@@ -206,7 +222,10 @@ export async function searchCachedPlaces(query: string, limit = 10): Promise<Cac
*/
export async function getCachedPlace(placeId: string): Promise<CachedAreaPlace | null> {
if (!placeId.startsWith('gers:')) return null
return (await offlineDb.areaPlaces.get(placeId.slice('gers:'.length))) ?? 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> {