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
@@ -0,0 +1,95 @@
/**
* FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent.
*
* The reason this file exists: the hook used to create object URLs and enqueue
* the preview state from inside a `setFiles` updater. React invokes an updater
* an extra time in development, so one picked image produced two preview URLs
* against one file, the composer strip drew two thumbnails for one attachment,
* and removing one of them left a preview for a picture that would not be sent.
* `renderHook` under StrictMode is what reproduces it.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StrictMode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
let nextUrl = 0
const revoked: string[] = []
beforeEach(() => {
nextUrl = 0
revoked.length = 0
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => `blob:mock/${++nextUrl}`),
revokeObjectURL: vi.fn((url: string) => { revoked.push(url) }),
})
})
const img = (name: string, size = 1024) =>
new File([new Uint8Array(size)], name, { type: 'image/png' })
const strict = () => renderHook(() => useChatImages(), { wrapper: StrictMode })
describe('useChatImages', () => {
it('FE-CHATIMG-001: one picked image is one file and one preview, in StrictMode', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
expect(result.current.files).toHaveLength(1)
expect(result.current.previews).toHaveLength(1)
expect(result.current.previews[0]).toBe('blob:mock/1')
})
it('FE-CHATIMG-002: removing the only image empties both lists and revokes its URL', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
act(() => { result.current.remove(0) })
expect(result.current.files).toEqual([])
expect(result.current.previews).toEqual([])
expect(revoked).toEqual(['blob:mock/1'])
})
it('FE-CHATIMG-003: files and previews stay in step across several adds', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png')]) })
act(() => { result.current.add([img('b.png'), img('c.png')]) })
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png'])
expect(result.current.previews).toEqual(['blob:mock/1', 'blob:mock/2', 'blob:mock/3'])
})
it('FE-CHATIMG-004: a rejected type is reported and nothing is pinned for it', () => {
const { result } = strict()
let ok = true
act(() => { ok = result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' })]) })
expect(ok).toBe(false)
expect(result.current.files).toEqual([])
expect(result.current.previews).toEqual([])
})
it('FE-CHATIMG-005: the cap holds and no URL is minted for what it turns away', () => {
const { result } = strict()
act(() => { result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`))) })
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
expect(result.current.previews).toHaveLength(MAX_CHAT_IMAGES)
})
it('FE-CHATIMG-006: clear revokes every URL it handed out', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png'), img('b.png')]) })
act(() => { result.current.clear() })
expect(result.current.files).toEqual([])
expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2'])
})
})
+44 -38
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
/**
* The images pinned to a chat message before it is sent.
@@ -7,16 +7,27 @@ import { useCallback, useEffect, useRef, useState } from 'react'
* their own copy of the same picking, previewing and clearing, which is two
* places for the same leak and a duplication bill on every line.
*
* Object URLs are held in a ref and revoked when the file they belong to goes,
* when the list is cleared and on unmount. Rebuilding every preview on each
* change, which is what both copies did, leaked one URL per image per keystroke
* of picking, and did it from inside a setState updater, so React ran it twice
* in development and leaked twice as fast.
* A file and the object URL that previews it are one thing, so they are stored
* as one thing and the two arrays the callers read are derived from it. Every
* mutator computes the next list first and then commits it: nothing is created
* or revoked inside a state updater, because React invokes those an extra time
* in development and the second run would mint a second URL for the same file.
* That is what the strip showed as two thumbnails for one attachment, and
* removing one of them left a preview for a picture that was never sent.
*
* The ref is the list, the state is the render of it. Written together so two
* calls in the same tick see each other, which a functional updater would give
* for free and a plain array would not.
*/
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export const MAX_CHAT_IMAGES = 4
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
interface PickedImage {
file: File
url: string
}
export interface ChatImages {
files: File[]
previews: string[]
@@ -27,51 +38,46 @@ export interface ChatImages {
}
export function useChatImages(): ChatImages {
const [files, setFiles] = useState<File[]>([])
const [previews, setPreviews] = useState<string[]>([])
const urls = useRef<string[]>([])
const [items, setItems] = useState<PickedImage[]>([])
const itemsRef = useRef<PickedImage[]>([])
const revokeAll = useCallback(() => {
urls.current.forEach(URL.revokeObjectURL)
urls.current = []
const commit = useCallback((next: PickedImage[]) => {
itemsRef.current = next
setItems(next)
}, [])
useEffect(() => revokeAll, [revokeAll])
// Unmount with pictures still pinned: the URLs outlive the component otherwise.
useEffect(() => () => {
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
itemsRef.current = []
}, [])
const add = useCallback((incoming: FileList | File[]) => {
const all = Array.from(incoming)
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
if (valid.length) {
setFiles(prev => {
const room = MAX_CHAT_IMAGES - prev.length
const added = valid.slice(0, Math.max(0, room))
// Only the new files get a URL; the ones already in the list keep theirs.
const fresh = added.map(URL.createObjectURL)
urls.current = [...urls.current, ...fresh]
setPreviews(p => [...p, ...fresh])
return [...prev, ...added]
})
const room = MAX_CHAT_IMAGES - itemsRef.current.length
const added = valid.slice(0, Math.max(0, room))
if (added.length) {
// Only the new files get a URL; the ones already in the list keep theirs.
commit([...itemsRef.current, ...added.map(file => ({ file, url: URL.createObjectURL(file) }))])
}
return valid.length === all.length
}, [])
}, [commit])
const remove = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
setPreviews(prev => {
const gone = prev[index]
if (gone) {
URL.revokeObjectURL(gone)
urls.current = urls.current.filter(u => u !== gone)
}
return prev.filter((_, i) => i !== index)
})
}, [])
const gone = itemsRef.current[index]
if (!gone) return
URL.revokeObjectURL(gone.url)
commit(itemsRef.current.filter((_, i) => i !== index))
}, [commit])
const clear = useCallback(() => {
revokeAll()
setFiles([])
setPreviews([])
}, [revokeAll])
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
commit([])
}, [commit])
const files = useMemo(() => items.map(item => item.file), [items])
const previews = useMemo(() => items.map(item => item.url), [items])
return { files, previews, add, remove, clear }
}