feat(person-mention): PR-B2 — read-mode rendering + hover card (issue #362) #371

Merged
marcel merged 18 commits from feat/person-mentions-issue-362-frontend-b2 into main 2026-04-29 13:37:06 +02:00
Showing only changes of commit 5890bb3abd - Show all commits

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;