diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 5d17e947..f1b189ce 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -420,6 +420,12 @@
"notification_unread": "ungelesen",
"mention_btn_label": "Person erwähnen",
"mention_popup_empty": "Keine Nutzer gefunden",
+ "person_mention_open_link": "Zur Person",
+ "person_mention_hover_hint": "Klick öffnet Seite",
+ "person_mention_load_error": "Person konnte nicht geladen werden.",
+ "person_mention_popup_empty": "Keine Personen gefunden",
+ "person_mention_btn_label": "Person verlinken",
+ "person_mention_create_new": "Neue Person anlegen",
"page_title_home": "Archiv",
"page_title_persons": "Personen",
"page_title_admin": "Administration",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 604aa617..02111802 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -420,6 +420,12 @@
"notification_unread": "unread",
"mention_btn_label": "Mention person",
"mention_popup_empty": "No users found",
+ "person_mention_open_link": "Open person",
+ "person_mention_hover_hint": "Click opens the page",
+ "person_mention_load_error": "Could not load person.",
+ "person_mention_popup_empty": "No persons found",
+ "person_mention_btn_label": "Link person",
+ "person_mention_create_new": "Create new person",
"page_title_home": "Archive",
"page_title_persons": "Persons",
"page_title_admin": "Administration",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 9e769d44..5d416017 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -420,6 +420,12 @@
"notification_unread": "no leído",
"mention_btn_label": "Mencionar persona",
"mention_popup_empty": "No se encontraron usuarios",
+ "person_mention_open_link": "Ir a la persona",
+ "person_mention_hover_hint": "Clic abre la página",
+ "person_mention_load_error": "No se pudo cargar la persona.",
+ "person_mention_popup_empty": "No se encontraron personas",
+ "person_mention_btn_label": "Vincular persona",
+ "person_mention_create_new": "Crear nueva persona",
"page_title_home": "Archivo",
"page_title_persons": "Personas",
"page_title_admin": "Administración",
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte
new file mode 100644
index 00000000..df686334
--- /dev/null
+++ b/frontend/src/lib/components/PersonMentionEditor.svelte
@@ -0,0 +1,263 @@
+
+
+
+
+
+ {#if popupOpen}
+
+ {#if loading}
+
{m.comp_typeahead_loading()}
+ {:else if results.length === 0}
+
+ {:else}
+ {#each results as person, i (person.id)}
+
{
+ e.preventDefault();
+ selectPerson(person);
+ }}
+ >
+ {person.displayName}
+ {#if formatLifeDateRange(person.birthYear, person.deathYear)}
+
+ {formatLifeDateRange(person.birthYear, person.deathYear)}
+
+ {/if}
+
+ {/each}
+ {/if}
+
+ {/if}
+
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
new file mode 100644
index 00000000..ea8df7fe
--- /dev/null
+++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
@@ -0,0 +1,385 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
+import type { components } from '$lib/generated/api';
+
+type Person = components['schemas']['Person'];
+type PersonMention = components['schemas']['PersonMention'];
+
+// Editor's internal search debounce is 200ms — drive it via fake timers
+// so tests are deterministic and fast (Tester #5506 §1).
+const DEBOUNCE_MS = 200;
+
+async function flushDebounce() {
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
+ // Let the awaited fetch resolve and the resulting state assignments flush.
+ await vi.runAllTimersAsync();
+}
+
+async function tick() {
+ await vi.advanceTimersByTimeAsync(0);
+}
+
+const AUGUSTE: Person = {
+ id: 'p-aug',
+ firstName: 'Auguste',
+ lastName: 'Raddatz',
+ displayName: 'Auguste Raddatz',
+ birthYear: 1882,
+ deathYear: 1944
+} as unknown as Person;
+
+const ANNA: Person = {
+ id: 'p-anna',
+ firstName: 'Anna',
+ lastName: 'Schmidt',
+ displayName: 'Anna Schmidt',
+ birthYear: 1860
+} as unknown as Person;
+
+function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+function mockFetchEmpty() {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+function mockFetchRejects() {
+ const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+function getTextarea(): HTMLTextAreaElement {
+ return document.querySelector('textarea')!;
+}
+
+function clickOption(personId: string) {
+ const opt = document.querySelector(
+ `[role="option"][data-test-person-id="${personId}"]`
+ ) as HTMLElement;
+ opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+}
+
+type Snapshot = { value: string; mentionedPersons: PersonMention[] };
+
+function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
+ let snapshot: Snapshot = {
+ value: initial.value ?? '',
+ mentionedPersons: initial.mentionedPersons ?? []
+ };
+ render(PersonMentionEditorHost, {
+ initialValue: initial.value ?? '',
+ initialMentions: initial.mentionedPersons ?? [],
+ onChange: (snap: Snapshot) => {
+ snapshot = snap;
+ }
+ });
+ return {
+ get snapshot() {
+ return snapshot;
+ }
+ };
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+// ─── Rendering ────────────────────────────────────────────────────────────────
+
+describe('PersonMentionEditor — rendering', () => {
+ it('renders the textarea with placeholder', async () => {
+ render(PersonMentionEditorHost, {
+ initialValue: '',
+ initialMentions: [],
+ placeholder: 'Transkription…',
+ onChange: () => {}
+ });
+ await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument();
+ });
+
+ it('reflects bound initial value', async () => {
+ render(PersonMentionEditorHost, {
+ initialValue: 'Hallo Welt',
+ initialMentions: [],
+ onChange: () => {}
+ });
+ await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
+ });
+});
+
+// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
+
+describe('PersonMentionEditor — typeahead', () => {
+ it('opens the popup when typing @ + query and shows results', async () => {
+ mockFetchWithPersons();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+
+ await flushDebounce();
+
+ await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
+ });
+
+ it('hits /api/persons?q= with the typed query', async () => {
+ const fetchMock = mockFetchWithPersons();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+
+ await flushDebounce();
+
+ expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
+ });
+
+ it('shows life dates next to the name in the dropdown', async () => {
+ mockFetchWithPersons();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no persons match', async () => {
+ mockFetchEmpty();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@xyz';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
+ });
+
+ it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => {
+ mockFetchRejects();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
+ });
+
+ it('keeps the popup open when the query has a trailing space (multi-word names)', async () => {
+ mockFetchWithPersons();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Auguste ';
+ ta.selectionStart = 9;
+ ta.selectionEnd = 9;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
+ });
+});
+
+// ─── Selection writes text + sidecar ─────────────────────────────────────────
+
+describe('PersonMentionEditor — selecting a person', () => {
+ it('inserts @DisplayName followed by a trailing space into the textarea', async () => {
+ mockFetchWithPersons();
+ const host = renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ clickOption('p-aug');
+ await tick();
+
+ expect(host.snapshot.value).toBe('@Auguste Raddatz ');
+ });
+
+ it('pushes {personId, displayName} into the bound mentionedPersons array', async () => {
+ mockFetchWithPersons();
+ const host = renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ clickOption('p-aug');
+ await tick();
+
+ expect(host.snapshot.mentionedPersons).toEqual([
+ { personId: 'p-aug', displayName: 'Auguste Raddatz' }
+ ]);
+ });
+
+ it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
+ mockFetchWithPersons();
+ const host = renderHost({
+ value: '@Auguste Raddatz ',
+ mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
+ });
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Auguste Raddatz @Aug';
+ ta.selectionStart = ta.value.length;
+ ta.selectionEnd = ta.value.length;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ clickOption('p-aug');
+ await tick();
+
+ expect(host.snapshot.mentionedPersons).toHaveLength(1);
+ });
+});
+
+// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
+
+describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
+ it('ArrowDown / ArrowUp cycle the highlighted result', async () => {
+ mockFetchWithPersons();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@A';
+ ta.selectionStart = 2;
+ ta.selectionEnd = 2;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ const optAuguste = document.querySelector(
+ '[role="option"][data-test-person-id="p-aug"]'
+ ) as HTMLElement;
+ const optAnna = document.querySelector(
+ '[role="option"][data-test-person-id="p-anna"]'
+ ) as HTMLElement;
+
+ expect(optAuguste.getAttribute('aria-selected')).toBe('true');
+ expect(optAnna.getAttribute('aria-selected')).toBe('false');
+
+ ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ await tick();
+
+ expect(optAuguste.getAttribute('aria-selected')).toBe('false');
+ expect(optAnna.getAttribute('aria-selected')).toBe('true');
+
+ ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
+ await tick();
+
+ expect(optAuguste.getAttribute('aria-selected')).toBe('true');
+ expect(optAnna.getAttribute('aria-selected')).toBe('false');
+ });
+
+ it('Enter selects the currently highlighted result', async () => {
+ mockFetchWithPersons();
+ const host = renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@A';
+ ta.selectionStart = 2;
+ ta.selectionEnd = 2;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ await tick();
+ ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ await tick();
+
+ expect(host.snapshot.mentionedPersons).toEqual([
+ { personId: 'p-anna', displayName: 'Anna Schmidt' }
+ ]);
+ });
+
+ it('Escape closes the popup without inserting anything', async () => {
+ mockFetchWithPersons();
+ const host = renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ await tick();
+
+ await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
+ expect(host.snapshot.value).toBe('@Aug');
+ expect(host.snapshot.mentionedPersons).toEqual([]);
+ });
+});
+
+// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
+
+describe('PersonMentionEditor — touch target', () => {
+ it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
+ mockFetchWithPersons();
+ renderHost();
+
+ const ta = getTextarea();
+ ta.focus();
+ ta.value = '@Aug';
+ ta.selectionStart = 4;
+ ta.selectionEnd = 4;
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await flushDebounce();
+
+ const option = document.querySelector('[role="option"]') as HTMLElement;
+ expect(option).not.toBeNull();
+ expect(option.className).toContain('min-h-[44px]');
+ });
+});
diff --git a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte
new file mode 100644
index 00000000..e608d694
--- /dev/null
+++ b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte
@@ -0,0 +1,31 @@
+
+
+
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte
index 74598c7e..982b43ff 100644
--- a/frontend/src/lib/components/TranscriptionBlock.svelte
+++ b/frontend/src/lib/components/TranscriptionBlock.svelte
@@ -2,6 +2,8 @@
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import CommentThread from './CommentThread.svelte';
+import PersonMentionEditor from './PersonMentionEditor.svelte';
+import type { PersonMention } from '$lib/types';
const { confirm } = getConfirmService();
@@ -12,13 +14,14 @@ type Props = {
documentId: string;
blockNumber: number;
text: string;
+ mentionedPersons: PersonMention[];
label: string | null;
active: boolean;
reviewed: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
- onTextChange: (text: string) => void;
+ onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
@@ -35,6 +38,7 @@ let {
documentId,
blockNumber,
text,
+ mentionedPersons,
label = null,
active,
reviewed,
@@ -54,10 +58,10 @@ let {
}: Props = $props();
let localText = $state(text);
+let localMentions = $state([...mentionedPersons]);
let commentOpen = $state(false);
let commentCount = $state(0);
let selectedQuote = $state(null);
-let textareaEl = $state(null);
const hasComments = $derived(commentCount > 0);
@@ -66,6 +70,7 @@ let prevBlockId = $state(blockId);
$effect(() => {
if (blockId !== prevBlockId) {
localText = text;
+ localMentions = [...mentionedPersons];
prevBlockId = blockId;
}
});
@@ -74,29 +79,19 @@ let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
-function autoresize(node: HTMLTextAreaElement) {
+// Single source of truth for the editor's textarea — stored on attach so
+// we can read selection bounds for quote selection without re-querying the DOM.
+let textareaEl: HTMLTextAreaElement | null = null;
+
+function captureTextarea(node: HTMLTextAreaElement) {
textareaEl = node;
- function resize() {
- node.style.height = 'auto';
- node.style.height = `${node.scrollHeight}px`;
- }
-
- resize();
-
- return {
- update() {
- resize();
- },
- destroy() {
- textareaEl = null;
- }
+ return () => {
+ textareaEl = null;
};
}
-function handleInput(event: Event) {
- const target = event.target as HTMLTextAreaElement;
- localText = target.value;
- onTextChange(target.value);
+function emitChange() {
+ onTextChange(localText, localMentions);
}
async function handleDelete() {
@@ -181,17 +176,24 @@ function handleTextareaMouseUp() {
{/if}
-
-
+
+
+
localText,
+ (v) => {
+ localText = v;
+ emitChange();
+ }}
+ bind:mentionedPersons={() => localMentions,
+ (next) => {
+ localMentions = next;
+ emitChange();
+ }}
+ placeholder={m.transcription_block_placeholder()}
+ onfocus={onFocus}
+ captureTextarea={captureTextarea}
+ />
+
{#if selectedQuote}
{m.transcription_block_quote_hint()}
diff --git a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte
index 9a03b4bf..d2e2055d 100644
--- a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte
+++ b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte
@@ -1,21 +1,24 @@
-
+
diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte
index eae2c825..64e97fef 100644
--- a/frontend/src/lib/components/TranscriptionEditView.svelte
+++ b/frontend/src/lib/components/TranscriptionEditView.svelte
@@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte';
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
-import type { TranscriptionBlockData } from '$lib/types';
+import type { PersonMention, TranscriptionBlockData } from '$lib/types';
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
@@ -16,7 +16,7 @@ type Props = {
storedScriptType?: string;
canRunOcr?: boolean;
onBlockFocus: (blockId: string) => void;
- onSaveBlock: (blockId: string, text: string) => Promise;
+ onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise;
onDeleteBlock: (blockId: string) => Promise;
onReviewToggle: (blockId: string) => Promise;
onMarkAllReviewed?: () => Promise;
@@ -245,16 +245,19 @@ async function handleLabelToggle(label: string) {
documentId={documentId}
blockNumber={i + 1}
text={block.text}
+ mentionedPersons={block.mentionedPersons ?? []}
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
saveState={autoSave.getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
- onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
+ onTextChange={(text, mentions) =>
+ autoSave.handleTextChange(block.id, text, mentions)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
- onRetry={() => autoSave.handleRetry(block.id, block.text)}
+ onRetry={() =>
+ autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts
index beeea901..39bf098c 100644
--- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts
+++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts
@@ -15,7 +15,8 @@ const block1 = {
sortOrder: 0,
version: 0,
source: 'MANUAL' as const,
- reviewed: false
+ reviewed: false,
+ mentionedPersons: []
};
const block2 = {
id: 'b2',
@@ -26,7 +27,8 @@ const block2 = {
sortOrder: 1,
version: 0,
source: 'OCR' as const,
- reviewed: true
+ reviewed: true,
+ mentionedPersons: []
};
function renderView(overrides: Record = {}, service = createConfirmService()) {
@@ -141,7 +143,28 @@ describe('TranscriptionEditView — auto-save debounce', () => {
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
- expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile');
+ expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []);
+ vi.useRealTimers();
+ });
+
+ it('passes the block mentionedPersons array as the 3rd save argument', async () => {
+ vi.useFakeTimers();
+ const onSaveBlock = vi.fn().mockResolvedValue(undefined);
+ const blockWithMention = {
+ ...block1,
+ mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
+ };
+ renderView({ blocks: [blockWithMention], onSaveBlock });
+
+ const textarea = page.getByRole('textbox').first();
+ await textarea.fill('Hallo @Auguste Raddatz');
+
+ vi.advanceTimersByTime(1500);
+ await vi.runAllTimersAsync();
+
+ expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
+ { personId: 'p-aug', displayName: 'Auguste Raddatz' }
+ ]);
vi.useRealTimers();
});
@@ -165,7 +188,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
// Only one save with the final value
expect(onSaveBlock).toHaveBeenCalledTimes(1);
- expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second');
+ expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
vi.useRealTimers();
});
});
@@ -220,7 +243,7 @@ describe('TranscriptionEditView — flush on blur', () => {
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
await vi.runAllTimersAsync();
- expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text');
+ expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
vi.useRealTimers();
});
});
diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts
index 70823be2..f49287cc 100644
--- a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts
+++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts
@@ -12,7 +12,10 @@ const blocks: TranscriptionBlockData[] = [
text: 'First paragraph text.',
label: null,
sortOrder: 1,
- version: 1
+ version: 1,
+ source: 'MANUAL',
+ reviewed: false,
+ mentionedPersons: []
},
{
id: 'b2',
@@ -21,7 +24,10 @@ const blocks: TranscriptionBlockData[] = [
text: 'Second paragraph text.',
label: null,
sortOrder: 2,
- version: 1
+ version: 1,
+ source: 'MANUAL',
+ reviewed: false,
+ mentionedPersons: []
}
];
@@ -49,7 +55,10 @@ describe('TranscriptionReadView', () => {
text: 'Text before [unleserlich] text after',
label: null,
sortOrder: 1,
- version: 1
+ version: 1,
+ source: 'MANUAL',
+ reviewed: false,
+ mentionedPersons: []
}
],
onParagraphClick: () => {}
@@ -71,7 +80,10 @@ describe('TranscriptionReadView', () => {
text: 'Some [...] text',
label: null,
sortOrder: 1,
- version: 1
+ version: 1,
+ source: 'MANUAL',
+ reviewed: false,
+ mentionedPersons: []
}
],
onParagraphClick: () => {}
diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts
index 739fb432..f6a87a8f 100644
--- a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts
+++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts
@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
+import type { PersonMention } from '$lib/types';
-const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise>();
+const mockSaveFn =
+ vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise>();
+
+const NO_MENTIONS: PersonMention[] = [];
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
@@ -22,25 +26,25 @@ describe('createBlockAutoSave', () => {
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text 1');
- as.handleTextChange('block-1', 'text 2');
- as.handleTextChange('block-1', 'text 3');
+ as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
+ as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
+ as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(1);
- expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
+ expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
});
it('handles concurrent blocks independently', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'hello');
- as.handleTextChange('block-2', 'world');
+ as.handleTextChange('block-1', 'hello', NO_MENTIONS);
+ as.handleTextChange('block-2', 'world', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
});
it('sets save state to saving then saved on success', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text');
+ as.handleTextChange('block-1', 'text', NO_MENTIONS);
vi.advanceTimersByTime(1500);
expect(as.getSaveState('block-1')).toBe('saving');
await Promise.resolve();
@@ -50,7 +54,7 @@ describe('createBlockAutoSave', () => {
it('sets save state to error on save failure', async () => {
mockSaveFn.mockRejectedValue(new Error('save failed'));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text');
+ as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
});
@@ -59,24 +63,49 @@ describe('createBlockAutoSave', () => {
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
mockSaveFn.mockResolvedValueOnce(undefined);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'original');
+ as.handleTextChange('block-1', 'original', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
- await as.handleRetry('block-1', 'original');
+ await as.handleRetry('block-1', 'original', NO_MENTIONS);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
expect(as.getSaveState('block-1')).toBe('saved');
});
+ it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
+ // Hold the second saveFn so we can observe the saving→saved transition
+ // (Tester #5506 §5).
+ let resolveSecond!: () => void;
+ mockSaveFn.mockRejectedValueOnce(new Error('boom'));
+ mockSaveFn.mockReturnValueOnce(new Promise((r) => (resolveSecond = r)));
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+
+ const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
+ as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(as.getSaveState('block-1')).toBe('error');
+
+ // Retry without re-passing the data — the hook resends the preserved payload.
+ const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
+ // Yield once so executeSave runs synchronously up to the saveFn await.
+ await Promise.resolve();
+ expect(as.getSaveState('block-1')).toBe('saving');
+ expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
+
+ resolveSecond();
+ await retryPromise;
+ expect(as.getSaveState('block-1')).toBe('saved');
+ });
+
it('clearBlock removes all state for a block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text');
+ as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.clearBlock('block-1');
expect(as.getSaveState('block-1')).toBe('idle');
});
it('destroy clears all pending timers so no save occurs', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text');
+ as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.destroy();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
@@ -101,8 +130,8 @@ describe('flushOnUnload', () => {
it('sends a PUT request with keepalive:true for each pending block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'hello');
- as.handleTextChange('block-2', 'world');
+ as.handleTextChange('block-1', 'hello', NO_MENTIONS);
+ as.handleTextChange('block-2', 'world', NO_MENTIONS);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -111,7 +140,7 @@ describe('flushOnUnload', () => {
expect.objectContaining({
method: 'PUT',
keepalive: true,
- body: JSON.stringify({ text: 'hello' })
+ body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
})
);
expect(mockFetch).toHaveBeenCalledWith(
@@ -119,7 +148,7 @@ describe('flushOnUnload', () => {
expect.objectContaining({
method: 'PUT',
keepalive: true,
- body: JSON.stringify({ text: 'world' })
+ body: JSON.stringify({ text: 'world', mentionedPersons: [] })
})
);
});
@@ -127,7 +156,7 @@ describe('flushOnUnload', () => {
it('does not call navigator.sendBeacon', () => {
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text');
+ as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
expect(sendBeaconSpy).not.toHaveBeenCalled();
@@ -142,7 +171,7 @@ describe('flushOnUnload', () => {
it('cancels the debounce timer so saveFn is not also called', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text');
+ as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
await vi.advanceTimersByTimeAsync(2000);
@@ -151,13 +180,26 @@ describe('flushOnUnload', () => {
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
- as.handleTextChange('block-1', 'text');
+ as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
- // debounce has fired; pendingTexts should be empty now
mockFetch.mockClear();
as.flushOnUnload();
expect(mockFetch).not.toHaveBeenCalled();
});
+
+ it('flushes the pending mentionedPersons sidecar alongside text', () => {
+ const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
+ const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
+ as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
+ as.flushOnUnload();
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/documents/doc-1/transcription-blocks/block-1',
+ expect.objectContaining({
+ body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
+ })
+ );
+ });
});
diff --git a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
index a0c541f3..3e4adaa2 100644
--- a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
+++ b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts
@@ -12,7 +12,8 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
sortOrder,
version: 1,
source: 'MANUAL',
- reviewed: false
+ reviewed: false,
+ mentionedPersons: []
};
}
diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
index 07dc5692..21de5442 100644
--- a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
+++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
@@ -1,9 +1,10 @@
import { SvelteMap } from 'svelte/reactivity';
+import type { PersonMention } from '$lib/types';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Options = {
- saveFn: (blockId: string, text: string) => Promise;
+ saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise;
documentId: string;
};
@@ -11,6 +12,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
const saveStates = new SvelteMap();
const debounceTimers = new SvelteMap>();
const pendingTexts = new SvelteMap();
+ const pendingMentions = new SvelteMap();
const fadeTimers: ReturnType[] = [];
function getSaveState(blockId: string): SaveState {
@@ -25,14 +27,19 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
+ const mentions = pendingMentions.get(blockId) ?? [];
pendingTexts.delete(blockId);
+ pendingMentions.delete(blockId);
setSaveState(blockId, 'saving');
try {
- await saveFn(blockId, text);
+ await saveFn(blockId, text, mentions);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
+ // Preserve in-flight payload so the user can retry without re-typing.
+ pendingTexts.set(blockId, text);
+ pendingMentions.set(blockId, mentions);
setSaveState(blockId, 'error');
}
}
@@ -69,8 +76,13 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
}
}
- function handleTextChange(blockId: string, text: string): void {
+ function handleTextChange(
+ blockId: string,
+ text: string,
+ mentionedPersons: PersonMention[]
+ ): void {
pendingTexts.set(blockId, text);
+ pendingMentions.set(blockId, mentionedPersons);
scheduleDebounce(blockId);
}
@@ -81,29 +93,37 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
}
}
- async function handleRetry(blockId: string, currentText: string): Promise {
- const pending = pendingTexts.get(blockId);
- const text = pending ?? currentText;
+ async function handleRetry(
+ blockId: string,
+ currentText: string,
+ currentMentions: PersonMention[]
+ ): Promise {
+ const text = pendingTexts.get(blockId) ?? currentText;
+ const mentions = pendingMentions.get(blockId) ?? currentMentions;
pendingTexts.set(blockId, text);
+ pendingMentions.set(blockId, mentions);
await executeSave(blockId);
}
function clearBlock(blockId: string): void {
clearDebounce(blockId);
pendingTexts.delete(blockId);
+ pendingMentions.delete(blockId);
saveStates.delete(blockId);
}
function flushOnUnload(): void {
for (const [blockId, text] of pendingTexts) {
+ const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId);
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ text }),
+ body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
});
pendingTexts.delete(blockId);
+ pendingMentions.delete(blockId);
}
}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 2458d35a..f9f810e7 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -37,6 +37,11 @@ export type Comment = {
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
+export type PersonMention = {
+ personId: string;
+ displayName: string;
+};
+
export type TranscriptionBlockData = {
id: string;
annotationId: string;
@@ -47,6 +52,7 @@ export type TranscriptionBlockData = {
version: number;
source: 'MANUAL' | 'OCR';
reviewed: boolean;
+ mentionedPersons: PersonMention[];
updatedAt?: string | null;
};
diff --git a/frontend/src/lib/utils/blockConflictMerge.spec.ts b/frontend/src/lib/utils/blockConflictMerge.spec.ts
new file mode 100644
index 00000000..e10b245d
--- /dev/null
+++ b/frontend/src/lib/utils/blockConflictMerge.spec.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect } from 'vitest';
+import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge';
+import type { PersonMention, TranscriptionBlockData } from '$lib/types';
+
+const baseBlock: TranscriptionBlockData = {
+ id: 'b1',
+ annotationId: 'a1',
+ documentId: 'd1',
+ text: 'old text from server',
+ label: null,
+ sortOrder: 0,
+ version: 7,
+ source: 'MANUAL',
+ reviewed: false,
+ mentionedPersons: []
+};
+
+describe('mergeBlockOnConflict', () => {
+ it('keeps the local unsaved text — never overwritten by server text (B12b)', () => {
+ const merged = mergeBlockOnConflict({
+ serverBlock: { ...baseBlock, text: 'server-side text' },
+ localText: 'transcriber unsaved input',
+ localMentions: []
+ });
+ expect(merged.text).toBe('transcriber unsaved input');
+ });
+
+ it('takes server-side displayName for personIds present on both sides (rename win)', () => {
+ const localMentions: PersonMention[] = [
+ { personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale: server renamed her
+ ];
+ const serverMentions: PersonMention[] = [
+ { personId: 'p-aug', displayName: 'Augusta Raddatz' } // post-rename
+ ];
+ const merged = mergeBlockOnConflict({
+ serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
+ localText: '@Augusta Raddatz',
+ localMentions
+ });
+ expect(merged.mentionedPersons).toEqual([
+ { personId: 'p-aug', displayName: 'Augusta Raddatz' }
+ ]);
+ });
+
+ it('keeps local-only mentions added since last save', () => {
+ const localMentions: PersonMention[] = [
+ { personId: 'p-anna', displayName: 'Anna Schmidt' } // typed since last save
+ ];
+ const merged = mergeBlockOnConflict({
+ serverBlock: { ...baseBlock, mentionedPersons: [] },
+ localText: '@Anna Schmidt',
+ localMentions
+ });
+ expect(merged.mentionedPersons).toContainEqual({
+ personId: 'p-anna',
+ displayName: 'Anna Schmidt'
+ });
+ });
+
+ it('returns a union of personIds when local and server diverge', () => {
+ const localMentions: PersonMention[] = [{ personId: 'p-anna', displayName: 'Anna Schmidt' }];
+ const serverMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }];
+ const merged = mergeBlockOnConflict({
+ serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
+ localText: '@Augusta Raddatz und @Anna Schmidt',
+ localMentions
+ });
+ expect(merged.mentionedPersons).toHaveLength(2);
+ expect(merged.mentionedPersons).toContainEqual({
+ personId: 'p-aug',
+ displayName: 'Augusta Raddatz'
+ });
+ expect(merged.mentionedPersons).toContainEqual({
+ personId: 'p-anna',
+ displayName: 'Anna Schmidt'
+ });
+ });
+
+ it('carries server version forward so the next save sends the latest revision', () => {
+ const merged = mergeBlockOnConflict({
+ serverBlock: { ...baseBlock, version: 42 },
+ localText: 'x',
+ localMentions: []
+ });
+ expect(merged.version).toBe(42);
+ });
+
+ it('carries server-only mention array through when local has none', () => {
+ const merged = mergeBlockOnConflict({
+ serverBlock: {
+ ...baseBlock,
+ mentionedPersons: [
+ { personId: 'p-aug', displayName: 'Augusta Raddatz' },
+ { personId: 'p-anna', displayName: 'Anna Schmidt' }
+ ]
+ },
+ localText: 'x',
+ localMentions: []
+ });
+ expect(merged.mentionedPersons).toHaveLength(2);
+ });
+
+ it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => {
+ const merged = mergeBlockOnConflict({
+ serverBlock: {
+ ...baseBlock,
+ sortOrder: 9,
+ reviewed: true,
+ updatedAt: '2026-04-29T10:00:00Z'
+ },
+ localText: 'x',
+ localMentions: []
+ });
+ expect(merged.sortOrder).toBe(9);
+ expect(merged.reviewed).toBe(true);
+ expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z');
+ });
+});
+
+describe('BlockConflictResolvedError', () => {
+ it('is an Error with code = CONFLICT_RESOLVED', () => {
+ const err = new BlockConflictResolvedError('block-1');
+ expect(err).toBeInstanceOf(Error);
+ expect(err.code).toBe('CONFLICT_RESOLVED');
+ expect(err.name).toBe('BlockConflictResolvedError');
+ expect(err.message).toContain('block-1');
+ });
+});
diff --git a/frontend/src/lib/utils/blockConflictMerge.ts b/frontend/src/lib/utils/blockConflictMerge.ts
new file mode 100644
index 00000000..7077d6c9
--- /dev/null
+++ b/frontend/src/lib/utils/blockConflictMerge.ts
@@ -0,0 +1,50 @@
+import type { PersonMention, TranscriptionBlockData } from '$lib/types';
+
+/**
+ * Sentinel thrown by saveBlockWithConflictRetry after a 409 rename-mid-edit
+ * has been merged into local state. Surfaces to the autosave hook as an
+ * error (so the UI shows the retry indicator), but distinguishable from a
+ * genuine network failure via the code. Carries the merged block snapshot
+ * on its `merged` property so the caller can update local state without
+ * a second roundtrip.
+ */
+export class BlockConflictResolvedError extends Error {
+ readonly code = 'CONFLICT_RESOLVED' as const;
+ merged?: TranscriptionBlockData;
+ constructor(blockId: string) {
+ super(
+ `Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result`
+ );
+ this.name = 'BlockConflictResolvedError';
+ }
+}
+
+type MergeArgs = {
+ serverBlock: TranscriptionBlockData;
+ localText: string;
+ localMentions: PersonMention[];
+};
+
+/**
+ * Resolves a 409-Conflict from the server by combining the latest server
+ * snapshot with the transcriber's unsaved local edits (B12b).
+ *
+ * Rules:
+ * - The transcriber's typed text always wins — never overwrite their input.
+ * - Server is the source of truth for the displayName of any person it
+ * knows about; renames that just landed on the server replace stale local
+ * names by personId.
+ * - Local-only mentions added since the last save are preserved.
+ * - All non-mention fields (version, sortOrder, reviewed, updatedAt, ...)
+ * come from the server snapshot so the next save sends the current
+ * revision and matches the latest persisted state.
+ */
+export function mergeBlockOnConflict(args: MergeArgs): TranscriptionBlockData {
+ const serverIds = new Set(args.serverBlock.mentionedPersons.map((m) => m.personId));
+ const localOnly = args.localMentions.filter((m) => !serverIds.has(m.personId));
+ return {
+ ...args.serverBlock,
+ text: args.localText,
+ mentionedPersons: [...args.serverBlock.mentionedPersons, ...localOnly]
+ };
+}
diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts
index 5a7982be..04b659b1 100644
--- a/frontend/src/lib/utils/mention.spec.ts
+++ b/frontend/src/lib/utils/mention.spec.ts
@@ -1,7 +1,42 @@
import { describe, it, expect } from 'vitest';
-import { detectMention, extractContent, renderBody } from './mention';
+import { detectMention, escapeHtml, extractContent, renderBody } from './mention';
import type { MentionDTO } from '$lib/types';
+// ─── escapeHtml ───────────────────────────────────────────────────────────────
+
+describe('escapeHtml', () => {
+ it('escapes ampersand', () => {
+ expect(escapeHtml('AT&T')).toBe('AT&T');
+ });
+
+ it('escapes less-than and greater-than', () => {
+ expect(escapeHtml('