refactor(person-mention): split fetchHoverData into pure load + cache wrapper

Felix #1: fetchHoverData was doing four things — cache lookup, fetch, JSON
parsing, 404 normalisation. Split into:

  loadHoverData(personId)       — pure fetch + 404→null + non-OK→throw
  getOrFetchHoverData(personId) — five-line cache wrapper around the above

Also document the cache-lifetime trade-off (Markus #4, Elicit OQ-372-02):
the cache is per-mount, so closing and reopening the transcription panel
rebuilds it. That's intentional given the read-only nature of the view —
revisit if stale-card user reports surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 08:54:35 +02:00
parent 060db69108
commit 5890bb3abd

View File

@@ -26,9 +26,14 @@ let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
// Per-page in-memory cache: a sweep across 20 mentions of the same person
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> shape
// lets simultaneous hovers share the same in-flight fetch.
// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
// shape lets simultaneous hovers share the same in-flight fetch.
//
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
// a person is rare in this read-only view, and a per-document/global cache would
// complicate invalidation. If user reports on stale cards accumulate, revisit.
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
const deletedPersonIds = new SvelteSet<string>();
@@ -57,25 +62,32 @@ function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
.join('') as SafeHtml;
}
function fetchHoverData(personId: string): Promise<HoverData | null> {
let cached = hoverCache.get(personId);
/**
* Fetches person + relationships from the backend. 404 returns null
* (deleted person — caller marks the link as tombstoned). Any other
* non-OK response throws so the caller can render the error state.
*/
async function loadHoverData(personId: string): Promise<HoverData | null> {
const personRes = await fetch(`/api/persons/${personId}`);
if (personRes.status === 404) return null;
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
const person = (await personRes.json()) as Person;
const relRes = await fetch(`/api/persons/${personId}/relationships`);
const relationships: RelationshipDTO[] = relRes.ok
? ((await relRes.json()) as RelationshipDTO[])
: [];
return { person, relationships };
}
/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
* subsequent hovers (and concurrent in-flight ones) share the same Promise. */
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
const cached = hoverCache.get(personId);
if (cached) return cached;
cached = (async () => {
const personRes = await fetch(`/api/persons/${personId}`);
if (personRes.status === 404) return null;
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
const person = (await personRes.json()) as Person;
const relRes = await fetch(`/api/persons/${personId}/relationships`);
const relationships: RelationshipDTO[] = relRes.ok
? ((await relRes.json()) as RelationshipDTO[])
: [];
return { person, relationships };
})();
hoverCache.set(personId, cached);
return cached;
const promise = loadHoverData(personId);
hoverCache.set(personId, promise);
return promise;
}
function currentViewport() {
@@ -102,7 +114,7 @@ async function handleMentionEnter(event: Event) {
activeCard = { personId, cardId, position, state: { status: 'loading' } };
try {
const data = await fetchHoverData(personId);
const data = await getOrFetchHoverData(personId);
// Bail if a different mention is now active
if (!activeCard || activeCard.personId !== personId) return;