From 4de664f4f609fdf2df56c5cfec469465982b7af7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:30:29 +0200 Subject: [PATCH 01/63] refactor(dragdrop): generalize createBlockDragDrop Removes the hard-typed TranscriptionBlockData constraint so JourneyEditor can reuse the pointer-drag module without importing transcription types. Selector contract (data-block-wrapper / data-drag-handle) unchanged. Adds type-regression guard test verified via tsc --noEmit. Co-Authored-By: Claude Sonnet 4.6 --- .../useBlockDragDrop.svelte.test.ts | 28 ++++++++++++++++++- .../transcription/useBlockDragDrop.svelte.ts | 11 ++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts index 2b756e4d..90afcad0 100644 --- a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts +++ b/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts @@ -1,7 +1,33 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, expectTypeOf } from 'vitest'; import { createBlockDragDrop } from './useBlockDragDrop.svelte'; import type { TranscriptionBlockData } from '$lib/shared/types'; +// --------------------------------------------------------------------------- +// Type-regression guard: createBlockDragDrop must accept any T extends {id: string} +// so JourneyEditor can reuse it without importing TranscriptionBlockData. +// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit +// until the function is made generic. +// --------------------------------------------------------------------------- +describe('createBlockDragDrop — generic type guard', () => { + it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => { + type SimpleItem = { id: string; position: number }; + const items: SimpleItem[] = [ + { id: 'item-1', position: 0 }, + { id: 'item-2', position: 1 } + ]; + const onReorder = vi.fn(); + const dd = createBlockDragDrop({ getSortedBlocks: () => items, onReorder }); + // Verify the hook is functional with the new type — state reads must work + expect(dd.draggedBlockId).toBeNull(); + expect(dd.dragOffsetY).toBe(0); + }); + + it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => { + // If the generic constraint is wrong this line fails tsc --noEmit + expectTypeOf(createBlockDragDrop).toBeFunction(); + }); +}); + function makeBlock(id: string, sortOrder: number): TranscriptionBlockData { return { id, diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts b/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts index 900c5d1c..49ac2a69 100644 --- a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts +++ b/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts @@ -1,11 +1,12 @@ -import type { TranscriptionBlockData } from '$lib/shared/types'; - -type Options = { - getSortedBlocks: () => TranscriptionBlockData[]; +type Options = { + getSortedBlocks: () => T[]; onReorder: (blockIds: string[]) => void; }; -export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) { +export function createBlockDragDrop({ + getSortedBlocks, + onReorder +}: Options) { let draggedBlockId = $state(null); let dropTargetIdx = $state(null); let dragOffsetY = $state(0); -- 2.49.1 From 65b79a337bbda712bf95eed116561bbfd6b12851 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:33:05 +0200 Subject: [PATCH 02/63] refactor(geschichte): extract GeschichteSidebar.svelte from GeschichteEditor Moves Status + Persons sections into a shared component so both GeschichteEditor (STORY) and the upcoming JourneyEditor (JOURNEY) can use the same sidebar without duplicating markup. Adds
mobile collapsibles with 44px summary hit areas. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/geschichte/GeschichteEditor.svelte | 32 +--------- .../lib/geschichte/GeschichteSidebar.svelte | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/geschichte/GeschichteSidebar.svelte diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte b/frontend/src/lib/geschichte/GeschichteEditor.svelte index 1448b73c..c325deba 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte @@ -5,7 +5,7 @@ import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; -import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte'; +import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte'; type Geschichte = components['schemas']['Geschichte']; type Person = components['schemas']['Person']; @@ -227,35 +227,7 @@ function exec(action: () => void) { - + diff --git a/frontend/src/lib/geschichte/GeschichteSidebar.svelte b/frontend/src/lib/geschichte/GeschichteSidebar.svelte new file mode 100644 index 00000000..31f70f04 --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteSidebar.svelte @@ -0,0 +1,60 @@ + + + -- 2.49.1 From a619f950a59e1bc7baeb5a1a975657ec285a2013 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:39:01 +0200 Subject: [PATCH 03/63] feat(journey-editor): add i18n keys, error codes, and interlude CSS tokens All 30+ journey_* message keys added to de/en/es.json. Four new ErrorCode values for journey item operations wired into errors.ts + getErrorMessage(). Interlude CSS primitives (--c-interlude-bg/border/label) defined for light and dark themes so JourneyItemRow can reference them via semantic aliases. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 33 ++++++++++++++++++++++++++++++- frontend/messages/en.json | 33 ++++++++++++++++++++++++++++++- frontend/messages/es.json | 33 ++++++++++++++++++++++++++++++- frontend/src/lib/shared/errors.ts | 12 +++++++++++ frontend/src/routes/layout.css | 20 +++++++++++++++++++ 5 files changed, 128 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 99c9c1b7..2767cc01 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1174,5 +1174,36 @@ "journey_item_open_aria_undated": "Brief öffnen", "journey_empty_state": "Diese Lesereise ist noch leer.", "journey_interlude_aria_label": "Kuratorennotiz", - "journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren." + "journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.", + "journey_add_document": "Brief hinzufügen", + "journey_add_interlude": "Zwischentext hinzufügen", + "journey_note_add": "Notiz hinzufügen", + "journey_note_remove": "Notiz entfernen", + "journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.", + "journey_intro_save_hint": "Wird mit 'Speichern' gesichert.", + "journey_already_added": "Bereits enthalten", + "journey_note_aria_label": "Kuratoren-Notiz für {title}", + "journey_drag_aria_label": "Reihenfolge von '{title}' ändern", + "journey_move_up": "'{title}' nach oben verschieben", + "journey_move_down": "'{title}' nach unten verschieben", + "journey_note_error": "Notiz konnte nicht gespeichert werden", + "journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben", + "journey_remove_confirm": "Wirklich entfernen?", + "journey_remove_confirm_yes": "Bestätigen", + "journey_remove_confirm_cancel": "Abbrechen", + "journey_mutation_error_reload": "Aktion fehlgeschlagen – bitte Seite neu laden.", + "journey_item_pending_add": "wird hinzugefügt…", + "journey_item_pending_remove": "wird entfernt…", + "journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.", + "journey_intro_placeholder": "Einleitung (optional)", + "journey_interlude_placeholder": "Zwischentext eingeben…", + "journey_add_interlude_confirm": "Hinzufügen", + "journey_edit_title_story": "Geschichte bearbeiten", + "journey_edit_title_journey": "Lesereise bearbeiten", + "journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich", + "journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.", + "error_journey_item_not_in_journey": "Dieser Eintrag gehört nicht zu dieser Lesereise.", + "error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).", + "error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.", + "error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0c4686a4..f2e4af9e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1174,5 +1174,36 @@ "journey_item_open_aria_undated": "Open letter", "journey_empty_state": "This reading journey is still empty.", "journey_interlude_aria_label": "Curator's note", - "journey_selector_aria_live_hint": "Please select a type to continue." + "journey_selector_aria_live_hint": "Please select a type to continue.", + "journey_add_document": "Add letter", + "journey_add_interlude": "Add interlude", + "journey_note_add": "Add note", + "journey_note_remove": "Remove note", + "journey_note_save_hint": "Saved when you leave the field.", + "journey_intro_save_hint": "Saved when you click 'Save'.", + "journey_already_added": "Already included", + "journey_note_aria_label": "Curator note for {title}", + "journey_drag_aria_label": "Change order of '{title}'", + "journey_move_up": "Move '{title}' up", + "journey_move_down": "Move '{title}' down", + "journey_note_error": "Could not save note", + "journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}", + "journey_remove_confirm": "Really remove?", + "journey_remove_confirm_yes": "Confirm", + "journey_remove_confirm_cancel": "Cancel", + "journey_mutation_error_reload": "Action failed – please reload the page.", + "journey_item_pending_add": "adding…", + "journey_item_pending_remove": "removing…", + "journey_published_empty_warning": "This journey will remain published without any entries.", + "journey_intro_placeholder": "Introduction (optional)", + "journey_interlude_placeholder": "Enter interlude text…", + "journey_add_interlude_confirm": "Add", + "journey_edit_title_story": "Edit story", + "journey_edit_title_journey": "Edit reading journey", + "journey_publish_disabled_title": "Title and at least one entry required", + "journey_save_hint_published": "Changes will be immediately visible to all readers.", + "error_journey_item_not_in_journey": "This entry does not belong to this reading journey.", + "error_journey_note_too_long": "The note is too long (maximum 2000 characters).", + "error_journey_document_already_added": "This letter is already included in the reading journey.", + "error_geschichte_type_immutable": "The type of a story cannot be changed after creation." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 3c8a361e..af712f47 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1174,5 +1174,36 @@ "journey_item_open_aria_undated": "Abrir carta", "journey_empty_state": "Este viaje de lectura está vacío.", "journey_interlude_aria_label": "Nota del curador", - "journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar." + "journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar.", + "journey_add_document": "Añadir carta", + "journey_add_interlude": "Añadir interludio", + "journey_note_add": "Añadir nota", + "journey_note_remove": "Eliminar nota", + "journey_note_save_hint": "Se guarda al salir del campo.", + "journey_intro_save_hint": "Se guarda al hacer clic en 'Guardar'.", + "journey_already_added": "Ya incluido", + "journey_note_aria_label": "Nota del curador para {title}", + "journey_drag_aria_label": "Cambiar el orden de '{title}'", + "journey_move_up": "Subir '{title}'", + "journey_move_down": "Bajar '{title}'", + "journey_note_error": "No se pudo guardar la nota", + "journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}", + "journey_remove_confirm": "¿Realmente eliminar?", + "journey_remove_confirm_yes": "Confirmar", + "journey_remove_confirm_cancel": "Cancelar", + "journey_mutation_error_reload": "Acción fallida – por favor recarga la página.", + "journey_item_pending_add": "añadiendo…", + "journey_item_pending_remove": "eliminando…", + "journey_published_empty_warning": "Este viaje permanecerá publicado sin entradas.", + "journey_intro_placeholder": "Introducción (opcional)", + "journey_interlude_placeholder": "Escribe el texto del interludio…", + "journey_add_interlude_confirm": "Añadir", + "journey_edit_title_story": "Editar historia", + "journey_edit_title_journey": "Editar viaje de lectura", + "journey_publish_disabled_title": "Se requiere título y al menos una entrada", + "journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.", + "error_journey_item_not_in_journey": "Esta entrada no pertenece a este viaje de lectura.", + "error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).", + "error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.", + "error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación." } diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index 59a0a846..203e35fc 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -47,9 +47,13 @@ export type ErrorCode = | 'DUPLICATE_RELATIONSHIP' | 'GESCHICHTE_NOT_FOUND' | 'JOURNEY_ITEM_NOT_FOUND' + | 'JOURNEY_ITEM_NOT_IN_JOURNEY' | 'JOURNEY_ITEM_POSITION_CONFLICT' | 'JOURNEY_AT_CAPACITY' + | 'JOURNEY_NOTE_TOO_LONG' + | 'JOURNEY_DOCUMENT_ALREADY_ADDED' | 'GESCHICHTE_TYPE_MISMATCH' + | 'GESCHICHTE_TYPE_IMMUTABLE' | 'INVALID_CREDENTIALS' | 'SESSION_EXPIRED' | 'MISSING_CREDENTIALS' @@ -170,12 +174,20 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_geschichte_not_found(); case 'JOURNEY_ITEM_NOT_FOUND': return m.error_journey_item_not_found(); + case 'JOURNEY_ITEM_NOT_IN_JOURNEY': + return m.error_journey_item_not_in_journey(); case 'JOURNEY_ITEM_POSITION_CONFLICT': return m.error_journey_item_position_conflict(); case 'JOURNEY_AT_CAPACITY': return m.error_journey_at_capacity(); + case 'JOURNEY_NOTE_TOO_LONG': + return m.error_journey_note_too_long(); + case 'JOURNEY_DOCUMENT_ALREADY_ADDED': + return m.error_journey_document_already_added(); case 'GESCHICHTE_TYPE_MISMATCH': return m.error_geschichte_type_mismatch(); + case 'GESCHICHTE_TYPE_IMMUTABLE': + return m.error_geschichte_type_immutable(); case 'INVALID_CREDENTIALS': return m.error_invalid_credentials(); case 'SESSION_EXPIRED': diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 8990d355..f3b6fe5c 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -82,6 +82,11 @@ --color-journey: var(--c-journey-text); --color-journey-border: var(--c-journey-border); + /* Interlude row — neutral surface with left accent border; ZWISCHENTEXT label */ + --color-interlude-bg: var(--c-interlude-bg); + --color-interlude-border: var(--c-interlude-border); + --color-interlude-label: var(--c-interlude-label); + /* Static brand tokens (not themed) */ --color-brand-navy: var(--palette-navy); --color-brand-mint: var(--palette-mint); @@ -139,6 +144,11 @@ --c-journey-text: #7a3f0e; --c-journey-border: #f0c99a; + /* Interlude (Zwischentext) — neutral warm surface with left accent border */ + --c-interlude-bg: #f5f4f0; + --c-interlude-border: #a1dcd8; + --c-interlude-label: #6b7280; + /* Tag color tokens — decorative dot colors on tag chips */ --c-tag-sage: #5a8a6a; --c-tag-sienna: #a0522d; @@ -263,6 +273,11 @@ --c-journey-bg: #3a2a1a; --c-journey-text: #e8862a; --c-journey-border: #7a4a1e; + + /* Interlude (Zwischentext) — KEEP IN SYNC with :root[data-theme='dark'] */ + --c-interlude-bg: #151c22; + --c-interlude-border: #00c7b1; + --c-interlude-label: #8b97a5; } } @@ -343,6 +358,11 @@ --c-journey-bg: #3a2a1a; --c-journey-text: #e8862a; --c-journey-border: #7a4a1e; + + /* Interlude (Zwischentext) — KEEP IN SYNC with the @media block above */ + --c-interlude-bg: #151c22; + --c-interlude-border: #00c7b1; + --c-interlude-label: #8b97a5; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */ -- 2.49.1 From 65d241f69eaf08e4b46229b1fba1641aa6f110da Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:43:47 +0200 Subject: [PATCH 04/63] feat(journey-editor): build DocumentPickerDropdown + refactor DocumentMultiSelect New DocumentPickerDropdown: single-select document search with aria-disabled for already-added items and sr-only "bereits enthalten" hint. DocumentMultiSelect refactored to use createTypeahead, removing raw setTimeout/debounceTimer. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/document/DocumentMultiSelect.svelte | 73 +++++------ .../document/DocumentPickerDropdown.svelte | 124 ++++++++++++++++++ .../DocumentPickerDropdown.svelte.spec.ts | 92 +++++++++++++ 3 files changed, 248 insertions(+), 41 deletions(-) create mode 100644 frontend/src/lib/document/DocumentPickerDropdown.svelte create mode 100644 frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte b/frontend/src/lib/document/DocumentMultiSelect.svelte index fbfd59a9..fba80f83 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte @@ -2,6 +2,7 @@ import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/shared/actions/clickOutside'; +import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte'; import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; import { getLocale } from '$lib/paraglide/runtime.js'; @@ -30,13 +31,29 @@ let { }: Props = $props(); let searchTerm = $state(''); -let results: DocumentOption[] = $state([]); -let showDropdown = $state(false); -let loading = $state(false); -let debounceTimer: ReturnType; let inputEl: HTMLInputElement; let dropdownStyle = $state(''); +const picker = createTypeahead({ + fetchUrl: (q) => + fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`) + .then((r) => r.json()) + .then((b: { items: DocumentListItem[] }) => + b.items.map((it) => ({ + id: it.id, + title: it.title, + documentDate: it.documentDate, + metaDatePrecision: it.metaDatePrecision, + metaDateEnd: it.metaDateEnd + })) + ) +}); + +// Filter out already-selected documents from typeahead results. +const filteredResults = $derived( + picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id)) +); + function updateDropdownPosition() { if (!inputEl) return; const rect = inputEl.getBoundingClientRect(); @@ -44,40 +61,17 @@ function updateDropdownPosition() { } function handleInput() { - showDropdown = true; - clearTimeout(debounceTimer); - debounceTimer = setTimeout(async () => { - if (searchTerm.length < 1) { - results = []; - return; - } - loading = true; - try { - const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`); - if (res.ok) { - const body: { items: DocumentListItem[] } = await res.json(); - const docs: DocumentOption[] = body.items.map((it) => ({ - id: it.id, - title: it.title, - documentDate: it.documentDate, - metaDatePrecision: it.metaDatePrecision, - metaDateEnd: it.metaDateEnd - })); - results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id)); - } - } catch { - results = []; - } finally { - loading = false; - } - }, 300); + if (searchTerm.trim().length >= 1) { + picker.setQuery(searchTerm); + } else { + picker.close(); + } } function selectDocument(doc: DocumentOption) { selectedDocuments = [...selectedDocuments, doc]; searchTerm = ''; - showDropdown = false; - results = []; + picker.close(); } function removeDocument(id: string | undefined) { @@ -103,7 +97,7 @@ function formatDocLabel(doc: DocumentOption): string { {/each} -
(showDropdown = false)}> +
picker.close()}>
@@ -136,24 +130,21 @@ function formatDocLabel(doc: DocumentOption): string { autocomplete="off" bind:value={searchTerm} oninput={handleInput} - onfocus={() => { - updateDropdownPosition(); - showDropdown = true; - }} + onfocus={() => updateDropdownPosition()} placeholder={placeholder} class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0" />
- {#if showDropdown && (results.length > 0 || loading)} + {#if picker.isOpen && (filteredResults.length > 0 || picker.loading)}
- {#if loading} + {#if picker.loading}
{m.comp_multiselect_loading()}
{:else} - {#each results as doc (doc.id)} + {#each filteredResults as doc (doc.id)}
selectDocument(doc)} diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte b/frontend/src/lib/document/DocumentPickerDropdown.svelte new file mode 100644 index 00000000..e9254baf --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte @@ -0,0 +1,124 @@ + + +
picker.close()} class="relative"> + 0} + aria-controls={listboxId} + aria-autocomplete="list" + placeholder={placeholder} + value={inputValue} + oninput={handleInput} + class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + /> + + {#if picker.isOpen && (picker.results.length > 0 || picker.loading)} +
    + {#if picker.loading} +
  • {m.comp_multiselect_loading()}
  • + {:else} + {#each picker.results as doc (doc.id)} + {@const disabled = alreadyAddedIds.has(doc.id!)} +
  • handleSelect(doc)} + onkeydown={(e) => e.key === 'Enter' && handleSelect(doc)} + tabindex={disabled ? -1 : 0} + class={[ + 'px-3 py-2 text-ink select-none', + disabled + ? 'cursor-default opacity-50' + : 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none' + ].join(' ')} + > + {formatDocLabel(doc)} + {#if disabled} + {m.journey_already_added()} + {/if} +
  • + {/each} + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts new file mode 100644 index 00000000..7529af4f --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import DocumentPickerDropdown from './DocumentPickerDropdown.svelte'; + +const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); + +const docFactory = (id: string, title: string) => ({ + id, + title, + documentDate: '1880-01-01', + metaDatePrecision: 'DAY' as const, + metaDateEnd: undefined +}); + +function mockSearchResponse(items: ReturnType[]) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ items }) + }) + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('DocumentPickerDropdown — empty query guard', () => { + it('does not call fetch on empty query', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + await userEvent.fill(page.getByRole('combobox'), ''); + await waitForDebounce(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('DocumentPickerDropdown — already-added indicator', () => { + it('shows already-added document as aria-disabled with sr-only hint', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); + + render(DocumentPickerDropdown, { + alreadyAddedIds: new Set(['d1']), + onSelect: vi.fn() + }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + + const disabledOption = page.getByRole('option', { name: /Brief von Eugenie/i }); + await expect.element(disabledOption).toHaveAttribute('aria-disabled', 'true'); + // Screen-reader text "bereits enthalten" must be present in the option + await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument(); + }); +}); + +describe('DocumentPickerDropdown — selection', () => { + it('calls onSelect with the item when a non-disabled option is clicked', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { onSelect }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.click(page.getByRole('option', { name: /Brief von Eugenie/i })); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); + }); + + it('does not call onSelect when an aria-disabled option is clicked', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { + alreadyAddedIds: new Set(['d1']), + onSelect + }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.click(page.getByRole('option', { name: /Brief von Eugenie/i })); + + expect(onSelect).not.toHaveBeenCalled(); + }); +}); -- 2.49.1 From d88cde06a0e4a47612041d3d8ae40d1360dca339 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:48:57 +0200 Subject: [PATCH 05/63] feat(journey-editor): build JourneyItemRow with note editing and remove confirm Item row with drag handle, move-up/down buttons, inline note textarea (PATCH on blur), interlude visual treatment, and inline confirm for removes that would discard a note. Interlude note cannot be cleared (blocked on empty). Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/geschichte/JourneyItemRow.svelte | 206 ++++++++++++++++++ .../geschichte/JourneyItemRow.svelte.spec.ts | 112 ++++++++++ 2 files changed, 318 insertions(+) create mode 100644 frontend/src/lib/geschichte/JourneyItemRow.svelte create mode 100644 frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte new file mode 100644 index 00000000..dd7a838c --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte @@ -0,0 +1,206 @@ + + +
+
+ + + + +
+ + +
+ + +
+ {#if isInterlude} + + {m.journey_add_interlude()} + + {:else} + {index + 1}. + {item.document!.title} + {/if} +
+ + +
+ {#if showRemoveConfirm} +
+ {m.journey_remove_confirm()} + + +
+ {:else} + + {/if} +
+
+ + + {#if showNote} +
+ +
+

{m.journey_note_save_hint()}

+ {#if !isInterlude} + + {/if} +
+ {#if noteError} + + {/if} +
+ {:else if !isInterlude} +
+ +
+ {/if} +
diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts new file mode 100644 index 00000000..3123aae7 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import JourneyItemRow from './JourneyItemRow.svelte'; + +const docItem = (overrides: Partial<{ note: string }> = {}) => ({ + id: 'item-1', + position: 0, + document: { id: 'doc-1', title: 'Brief von Karl', datePrecision: 'DAY' as const }, + ...overrides +}); + +const interludeItem = (note = 'Reise nach Wien') => ({ + id: 'item-2', + position: 1, + note +}); + +const defaultProps = (overrides = {}) => ({ + index: 0, + total: 3, + onMoveUp: vi.fn(), + onMoveDown: vi.fn(), + onRemove: vi.fn(), + onNotePatch: vi.fn().mockResolvedValue(undefined), + ...overrides +}); + +afterEach(() => cleanup()); + +describe('JourneyItemRow — note textarea', () => { + it('opens note textarea on "Notiz hinzufügen" click', async () => { + render(JourneyItemRow, { item: docItem(), ...defaultProps() }); + + await userEvent.click(page.getByText('Notiz hinzufügen')); + + await expect + .element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })) + .toBeInTheDocument(); + }); + + it('calls onNotePatch on textarea blur with non-empty value', async () => { + const onNotePatch = vi.fn().mockResolvedValue(undefined); + render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); + + await userEvent.click(page.getByText('Notiz hinzufügen')); + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); + await userEvent.fill(textarea, 'Eine neue Notiz'); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz'); + }); +}); + +describe('JourneyItemRow — interlude rules', () => { + it('does not show "Notiz entfernen" for interlude items', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps() }); + + // Note section should be visible (interlude always shows note) + await expect + .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) + .toBeInTheDocument(); + // But "Notiz entfernen" must be absent + await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument(); + }); + + it('blocks saving empty text on interlude note blur', async () => { + const onNotePatch = vi.fn().mockResolvedValue(undefined); + render(JourneyItemRow, { + item: interludeItem('original text'), + ...defaultProps({ onNotePatch }) + }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await userEvent.clear(textarea); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + expect(onNotePatch).not.toHaveBeenCalled(); + }); +}); + +describe('JourneyItemRow — remove confirm', () => { + it('shows inline confirm when removing a document item that has a note', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps() + }); + + // Click remove (x button) + await userEvent.click(page.getByRole('button', { name: 'Wirklich entfernen?' })); + + await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument(); + }); + + it('confirm cancel restores remove button without calling onRemove', async () => { + const onRemove = vi.fn(); + render(JourneyItemRow, { + item: docItem({ note: 'Notiz' }), + ...defaultProps({ onRemove }) + }); + + await userEvent.click(page.getByRole('button', { name: 'Wirklich entfernen?' })); + await userEvent.click(page.getByRole('button', { name: 'Abbrechen' })); + + expect(onRemove).not.toHaveBeenCalled(); + // The remove button should be back + await expect + .element(page.getByRole('button', { name: 'Wirklich entfernen?' })) + .toBeInTheDocument(); + }); +}); -- 2.49.1 From 9a178210fa5d752395430ae5fb4e8f5168f9e946 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:50:36 +0200 Subject: [PATCH 06/63] feat(journey-editor): build JourneyAddBar with document picker and interlude draft Two add buttons: document picker (DocumentPickerDropdown) and interlude inline draft form. Interlude confirm is aria-disabled until text is non-empty. Closing one panel opens the other. Tests cover all three plan test cases. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/geschichte/JourneyAddBar.svelte | 110 ++++++++++++++++++ .../geschichte/JourneyAddBar.svelte.spec.ts | 53 +++++++++ 2 files changed, 163 insertions(+) create mode 100644 frontend/src/lib/geschichte/JourneyAddBar.svelte create mode 100644 frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte b/frontend/src/lib/geschichte/JourneyAddBar.svelte new file mode 100644 index 00000000..d7651de7 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte @@ -0,0 +1,110 @@ + + +
+
+ + +
+ + {#if showPicker} + + {/if} + + {#if showInterludeForm} +
+ +
+ + +
+
+ {/if} +
diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts new file mode 100644 index 00000000..8fd7409b --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import JourneyAddBar from './JourneyAddBar.svelte'; + +afterEach(() => cleanup()); + +describe('JourneyAddBar — interlude flow', () => { + it('interlude confirm button is aria-disabled until text is non-empty', async () => { + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); + + await userEvent.click(page.getByText('Zwischentext hinzufügen')); + + const confirmBtn = page.getByRole('button', { name: 'Hinzufügen' }); + await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + it('confirm becomes active after typing text', async () => { + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); + + await userEvent.click(page.getByText('Zwischentext hinzufügen')); + await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise'); + + const confirmBtn = page.getByRole('button', { name: 'Hinzufügen' }); + await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'false'); + }); + + it('calls onAddInterlude with text on confirm', async () => { + const onAddInterlude = vi.fn(); + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude }); + + await userEvent.click(page.getByText('Zwischentext hinzufügen')); + await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien'); + await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' })); + + expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien'); + }); +}); + +describe('JourneyAddBar — document picker', () => { + it('reveals picker when "Brief hinzufügen" is clicked', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) }) + ); + render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); + + await userEvent.click(page.getByText('Brief hinzufügen')); + + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + vi.unstubAllGlobals(); + }); +}); -- 2.49.1 From a17eec537faad5442e38c4cf5d4b367418db5816 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:55:39 +0200 Subject: [PATCH 07/63] feat(journey-editor): build JourneyEditor orchestrator Main editing surface for JOURNEY-type Geschichten. Manages sorted item list with optimistic add/remove/reorder (rollback on failure), drag-and-drop reorder via createBlockDragDrop, intro textarea, and sidebar via GeschichteSidebar. Publish requires at least one item + non-empty title. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/geschichte/JourneyEditor.svelte | 321 ++++++++++++++++++ .../geschichte/JourneyEditor.svelte.spec.ts | 261 ++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 frontend/src/lib/geschichte/JourneyEditor.svelte create mode 100644 frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte new file mode 100644 index 00000000..ccc2a81a --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -0,0 +1,321 @@ + + + +
{liveAnnounce}
+ +
+ +
+ +
+ unsaved.markDirty()} + onblur={() => (titleTouched = true)} + placeholder={m.geschichte_editor_title_placeholder()} + aria-invalid={showTitleError} + aria-describedby={showTitleError ? 'journey-title-error' : undefined} + class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + /> + {#if showTitleError} + + {/if} +
+ + +
+ +

{m.journey_intro_save_hint()}

+
+ + + {#if showPublishedEmptyWarning} +

+ {m.journey_published_empty_warning()} +

+ {/if} + + {#if mutationError} + + {/if} + + +
dragDrop.handlePointerMove(e)} + onpointerup={() => dragDrop.handlePointerUp()} + class="flex flex-col gap-2" + > + {#each items as item, i (item.id)} + +
dragDrop.handleGripDown(e, item.id)} + class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}" + style={dragDrop.draggedBlockId === item.id + ? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;` + : ''} + > + {#if dragDrop.dropTargetIdx === i} +
+ {/if} + handleMoveUp(i)} + onMoveDown={() => handleMoveDown(i)} + onRemove={() => handleRemove(item.id)} + onNotePatch={(note) => handleNotePatch(item.id, note)} + /> +
+ {/each} +
+ + +
+ + + +
+ + +
+

+ {isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()} +

+
+ {#if isDraft} + + + {:else} + + + {/if} +
+
diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts new file mode 100644 index 00000000..2fd455e8 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import JourneyEditor from './JourneyEditor.svelte'; + +const docSummary = (id: string, title: string) => ({ + id, + title, + datePrecision: 'DAY' as const +}); + +const makeGeschichte = (overrides: Record = {}) => ({ + id: 'g1', + title: 'Briefe der Familie Raddatz', + body: '', + status: 'DRAFT' as const, + type: 'JOURNEY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00', + ...overrides +}); + +const defaultProps = (overrides: Record = {}) => ({ + geschichte: makeGeschichte(), + onSubmit: vi.fn().mockResolvedValue(undefined), + submitting: false, + ...overrides +}); + +function mockCsrfFetch(responseFactory: () => object) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(responseFactory()) + }) + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('JourneyEditor — empty state', () => { + it('renders title input and intro textarea', async () => { + render(JourneyEditor, defaultProps()); + await expect.element(page.getByRole('textbox', { name: /Titel/ })).not.toBeInTheDocument(); // input has no aria-label + // title input has placeholder text + await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument(); + }); + + it('publish button disabled when no items', async () => { + render(JourneyEditor, defaultProps()); + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); + }); +}); + +describe('JourneyEditor — items in position order', () => { + it('renders items sorted by position', async () => { + const items = [ + { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }, + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') } + ]; + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + const titles = await page.getByText(/Brief [AB]/).all(); + expect(titles.length).toBeGreaterThanOrEqual(2); + // Brief A should appear before Brief B (position 0 first) + const textContent = document.body.textContent ?? ''; + expect(textContent.indexOf('Brief A')).toBeLessThan(textContent.indexOf('Brief B')); + }); +}); + +describe('JourneyEditor — publish disabled when title empty', () => { + it('publish stays disabled until title is non-empty', async () => { + render( + JourneyEditor, + defaultProps({ + geschichte: makeGeschichte({ + items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] + }) + }) + ); + + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); + + const titleInput = page.getByPlaceholder(/Titel/); + await userEvent.fill(titleInput, 'Meine Reise'); + + await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); + }); +}); + +describe('JourneyEditor — add document', () => { + it('calls POST with documentId when document selected from picker', async () => { + const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }; + mockCsrfFetch(() => newItem); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + // picker search results + ok: true, + json: vi.fn().mockResolvedValue({ + items: [ + { + id: 'd1', + title: 'Brief von Karl', + documentDate: '1880-01-01', + metaDatePrecision: 'DAY', + originalFilename: 'brief.pdf', + receivers: [], + tags: [], + completionPercentage: 0, + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + status: 'UPLOADED', + metadataComplete: false, + scriptType: 'UNKNOWN', + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + ] + }) + }) + .mockResolvedValueOnce({ + // POST /items + ok: true, + json: vi.fn().mockResolvedValue(newItem) + }) + ); + + render(JourneyEditor, defaultProps()); + + await userEvent.click(page.getByText('Brief hinzufügen')); + await userEvent.fill(page.getByRole('combobox'), 'Karl'); + await new Promise((r) => setTimeout(r, 350)); // wait debounce + await userEvent.click(page.getByRole('option', { name: /Brief von Karl/ })); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items'), + expect.objectContaining({ method: 'POST' }) + ); + }); +}); + +describe('JourneyEditor — add interlude', () => { + it('calls POST with note on interlude confirm', async () => { + const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' }; + mockCsrfFetch(() => newItem); + + render(JourneyEditor, defaultProps()); + + await userEvent.click(page.getByText('Zwischentext hinzufügen')); + await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien'); + await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' })); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ note: 'Reise nach Wien' }) + }) + ); + }); +}); + +describe('JourneyEditor — remove with rollback', () => { + it('restores item on failed DELETE (non-ok response)', async () => { + const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + // Click remove (no note → direct remove) + await userEvent.click(page.getByRole('button', { name: 'Wirklich entfernen?' })); + await new Promise((r) => setTimeout(r, 50)); + + // Item should be restored after rollback + await expect.element(page.getByText('Brief A')).toBeInTheDocument(); + }); + + it('item-add does NOT mark dirty (isDirty stays false)', async () => { + const newItem = { id: 'i1', position: 0, note: 'Test' }; + mockCsrfFetch(() => newItem); + + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(JourneyEditor, defaultProps({ onSubmit })); + + // Add interlude (no unsaved warning should interfere) + await userEvent.click(page.getByText('Zwischentext hinzufügen')); + await userEvent.fill(page.getByRole('textbox'), 'Test'); + await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' })); + + // Saving (which requires non-empty title) — no unsaved warning dialog + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +describe('JourneyEditor — duplicate document aria-disabled', () => { + it('already-added document appears as aria-disabled in picker', async () => { + const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + items: [ + { + id: 'd1', + title: 'Brief von Karl', + documentDate: '1880-01-01', + metaDatePrecision: 'DAY', + originalFilename: 'brief.pdf', + receivers: [], + tags: [], + completionPercentage: 0, + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + status: 'UPLOADED', + metadataComplete: false, + scriptType: 'UNKNOWN', + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + ] + }) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + await userEvent.click(page.getByText('Brief hinzufügen')); + await userEvent.fill(page.getByRole('combobox'), 'Karl'); + await new Promise((r) => setTimeout(r, 350)); + + const option = page.getByRole('option', { name: /Brief von Karl/ }); + await expect.element(option).toHaveAttribute('aria-disabled', 'true'); + }); +}); -- 2.49.1 From ae0cb93a9ed3961b01635aa41b8fe35628bd409b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:57:44 +0200 Subject: [PATCH 08/63] feat(journey-editor): branch edit page on geschichte type Static imports for both editors; type-aware

title; JOURNEY type routes to JourneyEditor, STORY type continues to GeschichteEditor unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/geschichten/[id]/edit/+page.svelte | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte index dede091b..472b5238 100644 --- a/frontend/src/routes/geschichten/[id]/edit/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/edit/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { m } from '$lib/paraglide/messages.js'; import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte'; +import JourneyEditor from '$lib/geschichte/JourneyEditor.svelte'; import BackButton from '$lib/shared/primitives/BackButton.svelte'; import { getErrorMessage } from '$lib/shared/errors'; import { csrfFetch } from '$lib/shared/cookies'; @@ -12,6 +13,8 @@ let { data }: { data: PageData } = $props(); let submitting = $state(false); let errorMessage: string | null = $state(null); +const isJourney = $derived(data.geschichte.type === 'JOURNEY'); + async function handleSubmit(payload: { title: string; body: string; @@ -44,7 +47,8 @@ async function handleSubmit(payload: {

- {m.btn_edit()}: {data.geschichte.title} + {isJourney ? m.journey_edit_title_journey() : m.journey_edit_title_story()}: + {data.geschichte.title}

{#if errorMessage} @@ -56,5 +60,13 @@ async function handleSubmit(payload: {
{/if} - + {#if isJourney} + + {:else} + + {/if}
-- 2.49.1 From 1f9107b620a45ac2c9c82b2ec75e197655b55f97 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:59:58 +0200 Subject: [PATCH 09/63] docs(journey-editor): update README and strike stale spec references Add JourneyEditor, JourneyItemRow, JourneyAddBar, GeschichteSidebar to the geschichte README props table. Strike @dnd-kit/svelte-dnd-action library refs and raw orange-*/blue-600 color classes in the editor spec HTML. Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/lesereisen-editor-spec.html | 16 ++++++++-------- frontend/src/lib/geschichte/README.md | 24 ++++++++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/docs/specs/lesereisen-editor-spec.html b/docs/specs/lesereisen-editor-spec.html index 6b9ea590..c4565d7e 100644 --- a/docs/specs/lesereisen-editor-spec.html +++ b/docs/specs/lesereisen-editor-spec.html @@ -500,7 +500,7 @@ ElementWertHinweise Item-Zeile allgemein - Item-Containerflex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hiddeninterlude: bg-orange-50 border-orange-200 + Item-Containerflex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hiddeninterlude: bg-orange-50 border-orange-200--color-interlude-bg / --color-interlude-border CSS tokens Drag-Handlew-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0aria-label="Reihenfolge ändern"; cursor-grabbing während Drag Positions-Nr.w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0aus Array-Index, nicht item.position Entfernen-Buttonw-6 flex items-start justify-center pt-2 shrink-0× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden @@ -508,18 +508,18 @@ Brieftiteltext-[11px] font-semibold text-ink leading-snug mb-0.5document.title Briefmetatext-xs text-ink-3formatDate(doc.documentDate) · "von X" oder "von X an Y" Notiz-Textarea (sichtbar)w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2auto-expand; bind:value={item.note} - „Notiz hinzufügen" Linktext-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1togglet Notiz-Textarea + „Notiz hinzufügen" Linktext-xs font-semibold text-blue-600text-xs text-ink-3 underline hover:text-accenttogglet Notiz-Textarea „Notiz entfernen" Linktext-xs text-ink-3 inline-flex items-center gap-1 mt-1zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus Interlude-Item - Interlude-Containerbg-orange-50 border-orange-200 (überschreibt Item-Container)kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl - Label „Zwischentext"text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1immer sichtbar; nicht editierbar - Zwischentext-Textareaw-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400bind:value={item.note}; auto-expand; min 44px für Touch-Target + Interlude-Containerbg-orange-50 border-orange-200--color-interlude-bg left-accent border via --color-interlude-borderkein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl + Label „Zwischentext"text-orange-700color: var(--color-interlude-label)immer sichtbar; nicht editierbar + Zwischentext-Textareaborder-orange-200 focus:border-orange-400border-line focus-visible:ring-focus-ringbind:value={item.note}; auto-expand; min 44px für Touch-Target Aktionsleiste Add Barflex gap-2 pt-2 pb-1immer unten sichtbar, auch wenn Liste gefüllt „Brief hinzufügen" Buttonborder border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1öffnet existierende DocumentPicker-Komponente als Dropdown/Modal „Zwischentext hinzufügen" Buttongleich wie Brief-Buttonfügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea Drag-to-Reorder - Bibliothek@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)kein neues Package ohne Absprache + Bibliothek@dnd-kit/core oder svelte-dnd-actioncreateBlockDragDrop<JourneyItemView> aus $lib/document/transcription/useBlockDragDrop.sveltekein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt Reorder-API-CallPUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Itemsnach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren AccessibilityDrag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbrichtWCAG 2.1 SC 2.1.1 @@ -720,7 +720,7 @@ Split entfällt@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Endegleich wie GeschichteEditor auf Mobile Collapsiblesdetails/summary oder eigene boolean-Toggle; Personen + Status separatgeschlossen beim ersten Laden; Fokus öffnet Touch & Drag - Drag auf MobileLong-Press (500ms) auf dem Drag-Handle aktiviert Dragdnd-kit unterstützt Touch nativ; kein separates Config nötig + Drag auf MobileMove-Up/Down Buttons statt Drag (44px touch targets)dnd-kit unterstützt Touch nativ → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons Touch Target Itemsmin-h-[44px] für jede Item-ZeileWCAG 2.2 AA; durch Padding gesichert Add-Buttonsflex-1; volle verfügbare Breite geteiltmin-h-[44px] als Touch-Target Savebar @@ -779,7 +779,7 @@

Drag-to-Reorder

    -
  • Bibliothek: prüfe zunächst ob @dnd-kit/core oder svelte-dnd-action bereits im package.json ist. Kein neues Package einführen ohne Absprache.
  • +
  • Bibliothek: prüfe zunächst ob @dnd-kit/core oder svelte-dnd-action bereits im package.json ist. → Implementiert mit createBlockDragDrop<JourneyItemView> (kein externes Package).
  • Nach dem Drop: neue Reihenfolge als Array [{id, position}] berechnen (position = index * 10 lässt Lücken für künftige Inserts) und PUT /items/reorder senden.
  • Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".
diff --git a/frontend/src/lib/geschichte/README.md b/frontend/src/lib/geschichte/README.md index 49024cfc..f1e6e959 100644 --- a/frontend/src/lib/geschichte/README.md +++ b/frontend/src/lib/geschichte/README.md @@ -4,7 +4,7 @@ UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-te ## What this domain owns -Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`. +Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`. Utilities: `utils.ts`. ## What this domain does NOT own @@ -15,15 +15,19 @@ Utilities: `utils.ts`. ## Key components -| Component | Used in | Notes | -| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds | -| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page | -| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) | -| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions | -| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude | -| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `` for a document item; dated/undated aria-label, ✎ annotation glyph | -| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` | +| Component | Used in | Notes | +| -------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` | +| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `
` mobile collapsibles with 44px touch targets | +| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish | +| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm | +| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form | +| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page | +| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) | +| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions | +| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude | +| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `` for a document item; dated/undated aria-label, ✎ annotation glyph | +| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` | ## utils.ts -- 2.49.1 From ddcf61cc5e1a4e9a66e6bea91c9e8a541fe3b635 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 13:20:35 +0200 Subject: [PATCH 10/63] fix(tests): resolve 9 CI test failures in journey-editor specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useBlockDragDrop: add runtime expect() alongside expectTypeOf so browser-mode runner counts at least one assertion - JourneyAddBar: use exact:true on 'Hinzufügen' button — partial match was hitting '+ Brief hinzufügen' and '+ Zwischentext hinzufügen' too - JourneyEditor: fix 4 issues — drop wrong not.toBeInTheDocument() (placeholder creates accessible name); pass title:'' in publish-disabled test (default was non-empty); use getByPlaceholder for interlude textarea to avoid 4-element strict-mode violation; exact:true for 'Hinzufügen' button - DocumentPickerDropdown: use .click({force:true}) on aria-disabled option — userEvent refuses non-enabled elements Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentPickerDropdown.svelte.spec.ts | 3 ++- .../transcription/useBlockDragDrop.svelte.test.ts | 2 ++ .../src/lib/geschichte/JourneyAddBar.svelte.spec.ts | 6 +++--- .../src/lib/geschichte/JourneyEditor.svelte.spec.ts | 12 ++++++------ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts index 7529af4f..e60ef3be 100644 --- a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts @@ -85,7 +85,8 @@ describe('DocumentPickerDropdown — selection', () => { await userEvent.fill(page.getByRole('combobox'), 'Brief'); await waitForDebounce(); - await userEvent.click(page.getByRole('option', { name: /Brief von Eugenie/i })); + // aria-disabled items are not "enabled" — userEvent refuses them; use force click + await page.getByRole('option', { name: /Brief von Eugenie/i }).click({ force: true }); expect(onSelect).not.toHaveBeenCalled(); }); diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts index 90afcad0..f65f2dd5 100644 --- a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts +++ b/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts @@ -25,6 +25,8 @@ describe('createBlockDragDrop — generic type guard', () => { it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => { // If the generic constraint is wrong this line fails tsc --noEmit expectTypeOf(createBlockDragDrop).toBeFunction(); + // Runtime assertion so browser-mode doesn't report "no assertions" + expect(typeof createBlockDragDrop).toBe('function'); }); }); diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts index 8fd7409b..18c4936c 100644 --- a/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts @@ -11,7 +11,7 @@ describe('JourneyAddBar — interlude flow', () => { await userEvent.click(page.getByText('Zwischentext hinzufügen')); - const confirmBtn = page.getByRole('button', { name: 'Hinzufügen' }); + const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true }); await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'true'); }); @@ -21,7 +21,7 @@ describe('JourneyAddBar — interlude flow', () => { await userEvent.click(page.getByText('Zwischentext hinzufügen')); await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise'); - const confirmBtn = page.getByRole('button', { name: 'Hinzufügen' }); + const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true }); await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'false'); }); @@ -31,7 +31,7 @@ describe('JourneyAddBar — interlude flow', () => { await userEvent.click(page.getByText('Zwischentext hinzufügen')); await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien'); - await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' })); + await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true })); expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien'); }); diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts index 2fd455e8..52f51ea8 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -47,9 +47,8 @@ afterEach(() => { describe('JourneyEditor — empty state', () => { it('renders title input and intro textarea', async () => { render(JourneyEditor, defaultProps()); - await expect.element(page.getByRole('textbox', { name: /Titel/ })).not.toBeInTheDocument(); // input has no aria-label - // title input has placeholder text await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument(); + await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument(); }); it('publish button disabled when no items', async () => { @@ -80,6 +79,7 @@ describe('JourneyEditor — publish disabled when title empty', () => { JourneyEditor, defaultProps({ geschichte: makeGeschichte({ + title: '', items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] }) }) @@ -163,8 +163,8 @@ describe('JourneyEditor — add interlude', () => { render(JourneyEditor, defaultProps()); await userEvent.click(page.getByText('Zwischentext hinzufügen')); - await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien'); - await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' })); + await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Reise nach Wien'); + await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true })); expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining('/items'), @@ -203,8 +203,8 @@ describe('JourneyEditor — remove with rollback', () => { // Add interlude (no unsaved warning should interfere) await userEvent.click(page.getByText('Zwischentext hinzufügen')); - await userEvent.fill(page.getByRole('textbox'), 'Test'); - await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' })); + await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Test'); + await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true })); // Saving (which requires non-empty title) — no unsaved warning dialog await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); -- 2.49.1 From a5754162ceeaec455c41f76e25e0f6d99fbc658b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 14:43:42 +0200 Subject: [PATCH 11/63] fix(journey-editor): add required params to m.journey_item_moved() calls The screen-reader live announcement was calling m.journey_item_moved() without the required {position, total, newPosition} parameters, which the i18n template uses to build the full announcement string. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyEditor.svelte | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte index ccc2a81a..b77d245e 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -149,7 +149,11 @@ async function handleMoveUp(index: number) { if (index === 0) return; const ids = items.map((i) => i.id); [ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]; - liveAnnounce = m.journey_item_moved(); + liveAnnounce = m.journey_item_moved({ + position: index + 1, + total: items.length, + newPosition: index + }); await handleReorder(ids); } @@ -157,7 +161,11 @@ async function handleMoveDown(index: number) { if (index === items.length - 1) return; const ids = items.map((i) => i.id); [ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]; - liveAnnounce = m.journey_item_moved(); + liveAnnounce = m.journey_item_moved({ + position: index + 1, + total: items.length, + newPosition: index + 2 + }); await handleReorder(ids); } -- 2.49.1 From 573e5c43d7531c6cc3ae7a3a75095d88cdd278e8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 14:44:07 +0200 Subject: [PATCH 12/63] fix(journey-editor): render empty-state message when items list is empty Previously the item list area was blank when no items had been added. The empty-state paragraph uses the existing journey_empty_state i18n key. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyEditor.svelte | 3 +++ frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte index b77d245e..458520b4 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -244,6 +244,9 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') { onpointerup={() => dragDrop.handlePointerUp()} class="flex flex-col gap-2" > + {#if items.length === 0} +

{m.journey_empty_state()}

+ {/if} {#each items as item, i (item.id)}
{ render(JourneyEditor, defaultProps()); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); }); + + it('shows empty state message when items list is empty', async () => { + render(JourneyEditor, defaultProps()); + await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeInTheDocument(); + }); }); describe('JourneyEditor — items in position order', () => { -- 2.49.1 From dd917460b0503ff59955e7d24d7091bdf0a61f26 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 14:44:31 +0200 Subject: [PATCH 13/63] fix(journey-item-row): raise move buttons to WCAG 2.2 44px touch target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both move-up and move-down buttons had inline style="min-height: 22px" which is below the WCAG 2.2 success criterion 2.5.8 (44×44 CSS pixels minimum). Replaced with Tailwind min-h-[44px] min-w-[44px] classes. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyItemRow.svelte | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte index dd7a838c..0d872a72 100644 --- a/frontend/src/lib/geschichte/JourneyItemRow.svelte +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte @@ -86,8 +86,7 @@ function handleRemoveClick() { onclick={onMoveUp} disabled={index === 0} aria-label={m.journey_move_up({ title: itemTitle })} - class="flex items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20" - style="min-height: 22px; min-width: 44px;" + class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20" > @@ -98,8 +97,7 @@ function handleRemoveClick() { onclick={onMoveDown} disabled={index === total - 1} aria-label={m.journey_move_down({ title: itemTitle })} - class="flex items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20" - style="min-height: 22px; min-width: 44px;" + class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20" > -- 2.49.1 From 7e6030a4fc27ab0069903e76ffa395f3e157a870 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 14:45:13 +0200 Subject: [PATCH 14/63] fix(journey-item-row): add journey_remove_item_aria key and fix remove button label The remove button was using the confirmation-question text as its aria-label. Added a new dedicated journey_remove_item_aria key in all three locales so the button has a clear accessible name before the confirmation dialog opens. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/lib/geschichte/JourneyItemRow.svelte | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2767cc01..6aa43467 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1188,6 +1188,7 @@ "journey_move_down": "'{title}' nach unten verschieben", "journey_note_error": "Notiz konnte nicht gespeichert werden", "journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben", + "journey_remove_item_aria": "Eintrag entfernen", "journey_remove_confirm": "Wirklich entfernen?", "journey_remove_confirm_yes": "Bestätigen", "journey_remove_confirm_cancel": "Abbrechen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f2e4af9e..6fc18602 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1188,6 +1188,7 @@ "journey_move_down": "Move '{title}' down", "journey_note_error": "Could not save note", "journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}", + "journey_remove_item_aria": "Remove item", "journey_remove_confirm": "Really remove?", "journey_remove_confirm_yes": "Confirm", "journey_remove_confirm_cancel": "Cancel", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index af712f47..36d6865d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1188,6 +1188,7 @@ "journey_move_down": "Bajar '{title}'", "journey_note_error": "No se pudo guardar la nota", "journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}", + "journey_remove_item_aria": "Eliminar entrada", "journey_remove_confirm": "¿Realmente eliminar?", "journey_remove_confirm_yes": "Confirmar", "journey_remove_confirm_cancel": "Cancelar", diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte index 0d872a72..f2b21bee 100644 --- a/frontend/src/lib/geschichte/JourneyItemRow.svelte +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte @@ -144,7 +144,7 @@ function handleRemoveClick() {
{m.journey_empty_state()}

{/if} {#each items as item, i (item.id)} +
Date: Tue, 9 Jun 2026 17:18:00 +0200 Subject: [PATCH 26/63] fix(journey-item): enforce duplicate-document guard in append() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds JOURNEY_DOCUMENT_ALREADY_ADDED to ErrorCode, an existsByGeschichteIdAndDocumentId() repo method, and a 409 guard in JourneyItemService.append() — the error code was registered on the frontend but never thrown on the backend, allowing concurrent tabs to add the same document twice. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/exception/ErrorCode.java | 2 ++ .../journeyitem/JourneyItemRepository.java | 3 +++ .../journeyitem/JourneyItemService.java | 4 ++++ .../journeyitem/JourneyItemServiceTest.java | 16 ++++++++++++++++ 4 files changed, 25 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 1eaf8a2b..e38f8b0b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -128,6 +128,8 @@ public enum ErrorCode { JOURNEY_ITEM_POSITION_CONFLICT, /** The journey already has the maximum allowed number of items (100). 400 */ JOURNEY_AT_CAPACITY, + /** The document is already present in this journey — duplicate items are not allowed. 409 */ + JOURNEY_DOCUMENT_ALREADY_ADDED, /** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */ GESCHICHTE_TYPE_MISMATCH, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java index a1b3baee..34e7e26e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -30,6 +30,9 @@ public interface JourneyItemRepository extends JpaRepository /** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */ long countByGeschichteId(UUID geschichteId); + /** Dedup guard: true when the document is already linked to this journey. */ + boolean existsByGeschichteIdAndDocumentId(UUID geschichteId, UUID documentId); + /** * Loads journey items with their linked Document in a single JOIN FETCH query, * eliminating the N+1 SELECT that would occur when accessing item.getDocument() diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java index f189a9d3..2a9b5484 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -69,6 +69,10 @@ public class JourneyItemService { Document doc = null; if (dto.getDocumentId() != null) { + if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) { + throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED, + "Document already in journey: " + dto.getDocumentId()); + } doc = documentService.findSummaryByIdInternal(dto.getDocumentId()); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java index c411a3bb..93808775 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java @@ -288,6 +288,22 @@ class JourneyItemServiceTest { .isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY)); } + @Test + void append_returns409_when_document_already_in_journey() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L); + when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(docId); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED)); + } + @Test void cap_is_COUNT_based_not_MAX_position_based() { // 99 rows with MAX(position)=2000 should still accept the 100th append -- 2.49.1 From 949421a0760649d0da59f15166e5efc5f736f279 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:18:28 +0200 Subject: [PATCH 27/63] fix(geschichten): restore documentId filter via journey_items EXISTS subquery The PR removed the documentId filter from list() along with the old Geschichte.documents ManyToMany, but the document-detail page and its frontend server still query GET /api/geschichten?documentId= to show related stories. Without the filter the endpoint silently returned every published story. Restores the filter through a JPQL EXISTS check on journey_items so only journeys that include the given document are returned. Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichteController.java | 2 + .../geschichte/GeschichteRepository.java | 6 ++- .../geschichte/GeschichteService.java | 4 +- .../geschichte/GeschichteControllerTest.java | 10 ++--- .../GeschichteListProjectionTest.java | 16 ++++---- .../GeschichteServiceIntegrationTest.java | 18 ++++---- .../geschichte/GeschichteServiceTest.java | 41 ++++++++++++------- 7 files changed, 58 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java index 4eed4e16..73055218 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java @@ -37,10 +37,12 @@ public class GeschichteController { public List list( @RequestParam(required = false) GeschichteStatus status, @RequestParam(name = "personId", required = false) List personIds, + @RequestParam(required = false) UUID documentId, @RequestParam(required = false, defaultValue = "50") int limit) { return geschichteService.list( status, personIds == null ? List.of() : personIds, + documentId, limit); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java index f0947218..98c4bf13 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java @@ -33,11 +33,15 @@ public interface GeschichteRepository extends JpaRepository, J (SELECT COUNT(DISTINCT p.id) FROM Geschichte g2 JOIN g2.persons p WHERE g2.id = g.id AND p.id IN :personIds) = :personCount) + AND (:documentId IS NULL OR + EXISTS (SELECT 1 FROM JourneyItem ji + WHERE ji.geschichte = g AND ji.document.id = :documentId)) ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC """) List findSummaries( @Param("effectiveStatus") GeschichteStatus effectiveStatus, @Param("authorId") UUID authorId, @Param("personIds") Collection personIds, - @Param("personCount") long personCount); + @Param("personCount") long personCount, + @Param("documentId") UUID documentId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index 2a0d8819..571564e6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -105,7 +105,7 @@ public class GeschichteService { *

Returns a {@link GeschichteSummary} projection — never carries items, preventing * LazyInitializationException on the non-transactional list path. */ - public List list(GeschichteStatus status, List personIds, int limit) { + public List list(GeschichteStatus status, List personIds, UUID documentId, int limit) { GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); @@ -119,7 +119,7 @@ public class GeschichteService { long personCount = (personIds == null) ? 0 : personIds.size(); return geschichteRepository - .findSummaries(effective, authorId, safePersonIds, personCount) + .findSummaries(effective, authorId, safePersonIds, personCount, documentId) .stream() .limit(safeLimit) .toList(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index 9c7bf25c..f6a3b498 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -63,7 +63,7 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void list_returns200_forReader() throws Exception { - when(geschichteService.list(any(), any(), anyInt())) + when(geschichteService.list(any(), any(), any(), anyInt())) .thenReturn(List.of(summaryStub("Story A"))); mockMvc.perform(get("/api/geschichten")) @@ -75,13 +75,13 @@ class GeschichteControllerTest { @WithMockUser(authorities = "READ_ALL") void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception { UUID personId = UUID.randomUUID(); - when(geschichteService.list(any(), eq(List.of(personId)), anyInt())) + when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt())) .thenReturn(List.of()); mockMvc.perform(get("/api/geschichten").param("personId", personId.toString())) .andExpect(status().isOk()); - verify(geschichteService).list(any(), eq(List.of(personId)), anyInt()); + verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt()); } @Test @@ -89,7 +89,7 @@ class GeschichteControllerTest { void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception { UUID a = UUID.randomUUID(); UUID b = UUID.randomUUID(); - when(geschichteService.list(any(), eq(List.of(a, b)), anyInt())) + when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt())) .thenReturn(List.of()); mockMvc.perform(get("/api/geschichten") @@ -97,7 +97,7 @@ class GeschichteControllerTest { .param("personId", b.toString())) .andExpect(status().isOk()); - verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt()); + verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt()); } // ─── GET /api/geschichten/{id} ─────────────────────────────────────────── diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java index ec0d79a5..3c1cefcf 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java @@ -48,7 +48,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(draft("Entwurf", author)); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).hasSize(1); assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht"); @@ -59,7 +59,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(draft("Nur Entwurf", author)); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).isEmpty(); } @@ -73,7 +73,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(published("Briefe aus der Front", richAuthor)); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).hasSize(1); GeschichteSummary.AuthorSummary a = result.get(0).getAuthor(); @@ -94,7 +94,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(journey); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).hasSize(1); assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY); @@ -108,7 +108,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(draft("Fremder Entwurf", otherAuthor)); List result = geschichteRepository.findSummaries( - GeschichteStatus.DRAFT, author.getId(), sentinel(), 0); + GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null); assertThat(result).hasSize(1); assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf"); @@ -122,7 +122,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(published("B", author)); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, sentinel(), 0); + GeschichteStatus.PUBLISHED, null, sentinel(), 0, null); assertThat(result).hasSize(2); } @@ -143,7 +143,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(withAnna); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1); + GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null); assertThat(result).hasSize(1); assertThat(result.get(0).getTitle()).isEqualTo("Franz story"); @@ -164,7 +164,7 @@ class GeschichteListProjectionTest { geschichteRepository.save(onlyFranz); List result = geschichteRepository.findSummaries( - GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2); + GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null); assertThat(result).hasSize(1); assertThat(result.get(0).getTitle()).isEqualTo("Both"); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index 11db3c1a..f882e4f9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -90,7 +90,7 @@ class GeschichteServiceIntegrationTest { // Reader cannot see DRAFT in list authenticateAs(reader, Permission.READ_ALL); - assertThat(geschichteService.list(null, List.of(), 50)).isEmpty(); + assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty(); // Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND) UUID draftId = created.getId(); @@ -106,8 +106,8 @@ class GeschichteServiceIntegrationTest { // Reader can now see and fetch it authenticateAs(reader, Permission.READ_ALL); - assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1); - assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1); + assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1); + assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1); Geschichte fetched = geschichteService.getById(draftId); GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId)); assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz"); @@ -141,26 +141,26 @@ class GeschichteServiceIntegrationTest { authenticateAs(reader, Permission.READ_ALL); // No filter → all three - assertThat(geschichteService.list(null, List.of(), 50)) + assertThat(geschichteService.list(null, List.of(), null, 50)) .extracting(GeschichteSummary::getId) .containsExactlyInAnyOrder(storyAB, storyAC, storyA); // Single filter (Anna) → all three - assertThat(geschichteService.list(null, List.of(a.getId()), 50)) + assertThat(geschichteService.list(null, List.of(a.getId()), null, 50)) .extracting(GeschichteSummary::getId) .containsExactlyInAnyOrder(storyAB, storyAC, storyA); // AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC) - assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), 50)) + assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50)) .extracting(GeschichteSummary::getId) .containsExactly(storyAB); // AND: Bertha AND Carl → none (no story has both) - assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), 50)) + assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50)) .isEmpty(); // AND: Anna AND Bertha AND Carl → none - assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), 50)) + assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50)) .isEmpty(); } @@ -179,7 +179,7 @@ class GeschichteServiceIntegrationTest { geschichteService.create(dto); authenticateAs(writer2, Permission.BLOG_WRITE); - List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), 50); + List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50); assertThat(result).isEmpty(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index d2d5bfed..06b00490 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -222,12 +223,12 @@ class GeschichteServiceTest { @Test void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() { authenticateAs(reader, Permission.READ_ALL); - when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) .thenReturn(List.of()); - geschichteService.list(null, List.of(), 50); + geschichteService.list(null, List.of(), null, 50); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); } @Test @@ -235,25 +236,25 @@ class GeschichteServiceTest { authenticateAs(writer, Permission.BLOG_WRITE); GeschichteSummary s1 = mock(GeschichteSummary.class); GeschichteSummary s2 = mock(GeschichteSummary.class); - when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) .thenReturn(List.of(s1, s2)); - List out = geschichteService.list(null, List.of(), 50); + List out = geschichteService.list(null, List.of(), null, 50); assertThat(out).hasSize(2); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); } @Test void list_invokes_repository_findSummaries_when_filtering_by_single_personId() { authenticateAs(reader, Permission.READ_ALL); UUID personId = UUID.randomUUID(); - when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) .thenReturn(List.of()); - geschichteService.list(null, List.of(personId), 50); + geschichteService.list(null, List.of(personId), null, 50); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); } @Test @@ -261,21 +262,33 @@ class GeschichteServiceTest { authenticateAs(reader, Permission.READ_ALL); UUID a = UUID.randomUUID(); UUID b = UUID.randomUUID(); - when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) .thenReturn(List.of()); - geschichteService.list(null, List.of(a, b), 50); + geschichteService.list(null, List.of(a, b), null, 50); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); + } + + @Test + void list_passes_documentId_to_repository_as_journey_item_filter() { + authenticateAs(reader, Permission.READ_ALL); + UUID documentId = UUID.randomUUID(); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) + .thenReturn(List.of()); + + geschichteService.list(null, List.of(), documentId, 50); + + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId)); } @Test void list_caps_limit_at_max_when_caller_passes_huge_value() { authenticateAs(reader, Permission.READ_ALL); - when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) .thenReturn(List.of(mock(GeschichteSummary.class))); - List out = geschichteService.list(null, List.of(), 9999); + List out = geschichteService.list(null, List.of(), null, 9999); assertThat(out).hasSizeLessThanOrEqualTo(200); } -- 2.49.1 From e5f276a164830f1487939e211daa1ac5a6baf46d Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:50:58 +0200 Subject: [PATCH 28/63] =?UTF-8?q?test(journey-editor):=20add=20red=20test?= =?UTF-8?q?=20=E2=80=94=20PATCH=20body=20must=20send=20{"note":null}=20not?= =?UTF-8?q?=20{}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/JourneyEditor.svelte.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts index 32f2c048..7aa12599 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -310,6 +310,38 @@ describe('JourneyEditor — live announce region', () => { }); }); +describe('JourneyEditor — note patch body', () => { + it('sends {"note":null} when note textarea is cleared and blurred', async () => { + const items = [ + { id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' } + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi + .fn() + .mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }) + }) + ); + + render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await userEvent.clear(textarea); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + await new Promise((r) => setTimeout(r, 50)); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/items/i1'), + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ note: null }) + }) + ); + }); +}); + describe('JourneyEditor — duplicate document aria-disabled', () => { it('already-added document appears as aria-disabled in picker', async () => { const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }]; -- 2.49.1 From 63bc24d2f1d6f5f1031eb8180532b546c8502ea4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:51:26 +0200 Subject: [PATCH 29/63] fix(journey-editor): send {"note":null} on clear instead of omitting key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit null ?? undefined evaluated to undefined, causing JSON.stringify to omit the key entirely — the backend treated an absent note field as a no-op, so clearing a note never persisted. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyEditor.svelte | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte index a0a4da72..89131f2e 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -43,6 +43,7 @@ let items: JourneyItemView[] = $state( let titleTouched = $state(false); let mutationError = $state(''); let liveAnnounce = $state(''); +let announceTimer: ReturnType | null = null; const titleEmpty = $derived(title.trim().length === 0); const showTitleError = $derived(titleEmpty && titleTouched); @@ -138,7 +139,7 @@ async function handleNotePatch(itemId: string, note: string | null) { const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ note: note ?? undefined }) + body: JSON.stringify({ note: note }) }); if (!res.ok) throw new Error('note patch failed'); const updated: JourneyItemView = await res.json(); @@ -156,8 +157,10 @@ async function handleMoveUp(index: number) { }); await handleReorder(ids); // Clear so the live region does not re-announce on unrelated DOM mutations - setTimeout(() => { + if (announceTimer) clearTimeout(announceTimer); + announceTimer = setTimeout(() => { liveAnnounce = ''; + announceTimer = null; }, 500); } @@ -172,8 +175,10 @@ async function handleMoveDown(index: number) { }); await handleReorder(ids); // Clear so the live region does not re-announce on unrelated DOM mutations - setTimeout(() => { + if (announceTimer) clearTimeout(announceTimer); + announceTimer = setTimeout(() => { liveAnnounce = ''; + announceTimer = null; }, 500); } -- 2.49.1 From 1cfa03d1f0f56a2461247802756dc661b813cf59 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:51:49 +0200 Subject: [PATCH 30/63] =?UTF-8?q?test(journey-item-row):=20add=20red=20tes?= =?UTF-8?q?t=20=E2=80=94=20note=20remove=20must=20restore=20on=20patch=20f?= =?UTF-8?q?ailure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/JourneyItemRow.svelte.spec.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts index b7d62022..c22c874a 100644 --- a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts @@ -66,6 +66,26 @@ describe('JourneyItemRow — note error state', () => { }); }); +describe('JourneyItemRow — note remove error state', () => { + it('restores note and shows error when onNotePatch rejects during remove', async () => { + const onNotePatch = vi.fn().mockRejectedValue(new Error('server error')); + render(JourneyItemRow, { + item: docItem({ note: 'keep me' }), + ...defaultProps({ onNotePatch }) + }); + + await userEvent.click(page.getByText('Notiz entfernen')); + await new Promise((r) => setTimeout(r, 50)); + + // textarea should be visible again (showNote restored) + await expect + .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) + .toBeInTheDocument(); + // error alert should be shown + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + }); +}); + describe('JourneyItemRow — interlude rules', () => { it('does not show "Notiz entfernen" for interlude items', async () => { render(JourneyItemRow, { item: interludeItem(), ...defaultProps() }); -- 2.49.1 From 55989058a59a4b29eb2ed4ab45e0506d737d1bb1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:52:15 +0200 Subject: [PATCH 31/63] fix(journey-item-row): restore note state and show error when remove patch fails handleNoteRemove mutated UI state optimistically without try/catch. A failed PATCH left the note visually deleted while it survived on the server. Now uses snapshot/rollback identical to handleNoteBlur. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyItemRow.svelte | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte index f2b21bee..42d79e51 100644 --- a/frontend/src/lib/geschichte/JourneyItemRow.svelte +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte @@ -43,10 +43,18 @@ async function handleNoteBlur() { } async function handleNoteRemove() { + const prevDraft = noteDraft; + const prevShowNote = showNote; noteDraft = ''; showNote = false; noteError = ''; - await onNotePatch(null); + try { + await onNotePatch(null); + } catch { + noteDraft = prevDraft; + showNote = prevShowNote; + noteError = m.journey_note_error(); + } } function handleRemoveClick() { -- 2.49.1 From e1ca2c6831f40a179a184c9d89807bd50729d8fc Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:52:47 +0200 Subject: [PATCH 32/63] =?UTF-8?q?test(journey-add-bar):=20add=20red=20test?= =?UTF-8?q?=20=E2=80=94=20confirm=20button=20must=20be=20natively=20disabl?= =?UTF-8?q?ed=20(WCAG=204.1.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts index 2bbc5988..f42c61db 100644 --- a/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts @@ -9,23 +9,23 @@ afterEach(() => { }); describe('JourneyAddBar — interlude flow', () => { - it('interlude confirm button is aria-disabled until text is non-empty', async () => { + it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => { render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); await userEvent.click(page.getByText('Zwischentext hinzufügen')); const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true }); - await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'true'); + await expect.element(confirmBtn).toBeDisabled(); }); - it('confirm becomes active after typing text', async () => { + it('confirm becomes enabled after typing text', async () => { render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); await userEvent.click(page.getByText('Zwischentext hinzufügen')); await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise'); const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true }); - await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'false'); + await expect.element(confirmBtn).toBeEnabled(); }); it('calls onAddInterlude with text on confirm', async () => { -- 2.49.1 From b30c0d7f960cd1c1912af40867ebafb546d261f7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:53:08 +0200 Subject: [PATCH 33/63] fix(journey-add-bar): replace aria-disabled with native disabled on confirm button aria-disabled alone leaves the button keyboard-activatable, violating WCAG 4.1.2. Native disabled removes it from the tab order and prevents activation via Enter/Space. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyAddBar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte b/frontend/src/lib/geschichte/JourneyAddBar.svelte index d7651de7..0ce003f2 100644 --- a/frontend/src/lib/geschichte/JourneyAddBar.svelte +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte @@ -87,7 +87,7 @@ function handleInterludeCancel() {

{/each} {/if} diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte b/frontend/src/lib/document/DocumentPickerDropdown.svelte index e80630e2..c6de5f4e 100644 --- a/frontend/src/lib/document/DocumentPickerDropdown.svelte +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte @@ -1,16 +1,11 @@
picker.close()} class="relative"> @@ -113,7 +83,7 @@ function formatDocLabel(doc: DocumentOption): string { : 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none' ].join(' ')} > - {formatDocLabel(doc)} + {formatDocumentOption(doc)} {#if disabled} {m.journey_already_added()} {/if} diff --git a/frontend/src/lib/document/documentTypeahead.ts b/frontend/src/lib/document/documentTypeahead.ts new file mode 100644 index 00000000..4853298a --- /dev/null +++ b/frontend/src/lib/document/documentTypeahead.ts @@ -0,0 +1,40 @@ +import type { components } from '$lib/generated/api'; +import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte'; +import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; +import { getLocale } from '$lib/paraglide/runtime.js'; + +type DocumentListItem = components['schemas']['DocumentListItem']; + +export type DocumentOption = Pick< + DocumentListItem, + 'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd' +>; + +export function createDocumentTypeahead() { + return createTypeahead({ + fetchUrl: (q) => + fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`) + .then((r) => r.json()) + .then((b: { items: DocumentListItem[] }) => + b.items.map((it) => ({ + id: it.id, + title: it.title, + documentDate: it.documentDate, + metaDatePrecision: it.metaDatePrecision, + metaDateEnd: it.metaDateEnd + })) + ) + }); +} + +export function formatDocumentOption(doc: DocumentOption): string { + if (!doc.documentDate) return doc.title; + const label = formatDocumentDate( + doc.documentDate, + doc.metaDatePrecision as DatePrecision, + doc.metaDateEnd, + null, + getLocale() + ); + return `${doc.title} · ${label}`; +} diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte b/frontend/src/lib/geschichte/JourneyAddBar.svelte index 0ce003f2..c9af4b61 100644 --- a/frontend/src/lib/geschichte/JourneyAddBar.svelte +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte @@ -1,13 +1,7 @@ + + +
+ {#if errorMessage} + + {/if} + +
+ (titleTouched = true)} + placeholder={m.geschichte_editor_title_placeholder()} + aria-invalid={showTitleError} + class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError + ? 'border-danger' + : 'border-line'}" + /> + {#if showTitleError} +

{m.geschichte_editor_title_required()}

+ {/if} +
+ +
+
diff --git a/frontend/src/routes/geschichten/new/page.svelte.test.ts b/frontend/src/routes/geschichten/new/page.svelte.test.ts index 26a0ad49..ccf76f8d 100644 --- a/frontend/src/routes/geschichten/new/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/new/page.svelte.test.ts @@ -73,14 +73,13 @@ describe('geschichten/new page', () => { await expect.element(page.getByRole('radiogroup')).toBeVisible(); }); - it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => { + it('shows JourneyCreate form when selectedType is JOURNEY', async () => { render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); - const placeholder = document.querySelector('[data-testid="journey-placeholder"]'); - expect(placeholder).not.toBeNull(); + await expect.element(page.getByRole('button', { name: /Lesereise erstellen/i })).toBeVisible(); }); - it('JOURNEY placeholder offers a return-to-selection link', async () => { + it('JOURNEY create form offers a return-to-selection link', async () => { render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); const backLink = page.getByRole('link', { name: /andere Auswahl/i }); -- 2.49.1 From bd5e6e6fbe7e7ce361aca5a15603bf87d10343c4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 19:38:22 +0200 Subject: [PATCH 45/63] fix(test): narrow getByText selector to dropdown-only item in aria-disabled test Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts index 496f0358..25263952 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -386,8 +386,9 @@ describe('JourneyEditor — duplicate document aria-disabled', () => { await userEvent.fill(page.getByRole('combobox'), 'Karl'); await new Promise((r) => setTimeout(r, 350)); + // The dropdown item includes the date ("Brief von Karl · …"), the list item does not const option = page - .getByText(/Brief von Karl/) + .getByText(/Brief von Karl ·/) .element() .closest('li')!; expect(option.getAttribute('aria-disabled')).toBe('true'); -- 2.49.1 From 44f15dd4a271597c90fe5d9727454d935adc789c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 22:02:58 +0200 Subject: [PATCH 46/63] =?UTF-8?q?fix(geschichte):=20persist=20GeschichteTy?= =?UTF-8?q?pe=20on=20create=20=E2=80=94=20JOURNEY=20was=20silently=20dropp?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeschichteUpdateDTO lacked a `type` field, so the `type: 'JOURNEY'` sent by JourneyCreate was discarded by Jackson and every new Geschichte was saved as STORY. The edit page branched on type, so journeys always showed the STORY editor with no document-adding capability. Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichteService.java | 1 + .../geschichte/GeschichteUpdateDTO.java | 1 + .../geschichte/GeschichteServiceTest.java | 31 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index 571564e6..9cb957a2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -134,6 +134,7 @@ public class GeschichteService { .title(dto.getTitle().trim()) .body(sanitize(dto.getBody())) .status(GeschichteStatus.DRAFT) + .type(dto.getType() != null ? dto.getType() : GeschichteType.STORY) .author(currentUser()) .persons(resolvePersons(dto.getPersonIds())) .build(); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java index 969ca6dd..c2b78597 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java @@ -15,5 +15,6 @@ public class GeschichteUpdateDTO { private String title; private String body; private GeschichteStatus status; + private GeschichteType type; private List personIds; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 06b00490..20c65e1b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -389,6 +389,37 @@ class GeschichteServiceTest { .isEqualTo(ErrorCode.VALIDATION_ERROR); } + @Test + void create_preserves_JOURNEY_type_from_dto() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("My Journey"); + dto.setType(GeschichteType.JOURNEY); + + Geschichte saved = geschichteService.create(dto); + + assertThat(saved.getType()).isEqualTo(GeschichteType.JOURNEY); + } + + @Test + void create_defaults_to_STORY_when_type_is_null() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("My Story"); + + Geschichte saved = geschichteService.create(dto); + + assertThat(saved.getType()).isEqualTo(GeschichteType.STORY); + } + // ─── update ────────────────────────────────────────────────────────────── @Test -- 2.49.1 From 522d1f3ec9f21bed01dbf72cbb38e1ac5f77ccf6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 22:58:13 +0200 Subject: [PATCH 47/63] fix(search): load relevance-path documents with Document.list entity graph The pure-text RELEVANCE fast path loaded documents via plain findAllById, which carries no entity graph. With Document.tags LAZY (ADR-022) and no surrounding transaction, resolveDocumentTagColors hit the dead proxy and every q-only search (document picker typeaheads) failed with 500 LazyInitializationException. Dedicated findByIdIn declares the same fetch shape as the other search loaders. Co-Authored-By: Claude Fable 5 --- .../document/DocumentRepository.java | 7 ++++++ .../document/DocumentService.java | 2 +- .../document/DocumentLazyLoadingTest.java | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java index 1cfe26f6..72a9fd03 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentRepository.java @@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository, JpaSp @EntityGraph("Document.list") Page findAll(Pageable pageable); + // Loader for the relevance fast path: list-item enrichment reads tags after the + // repository call returns, so the fetch shape must match the spec-based findAll + // overloads above. Plain findAllById carries no entity graph and must not feed + // enrichItems — see DocumentService.relevanceSortedPageFromSql. + @EntityGraph("Document.list") + List findByIdIn(Collection ids); + // Findet ein Dokument anhand des ursprünglichen Dateinamens // Wichtig für den Abgleich beim Excel-Import & Datei-Upload Optional findByOriginalFilename(String originalFilename); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index d4715f40..3c9814ec 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -858,7 +858,7 @@ public class DocumentService { rankMap.put(ftsPage.hits().get(i).id(), i); pageIds.add(ftsPage.hits().get(i).id()); } - List docs = documentRepository.findAllById(pageIds).stream() + List docs = documentRepository.findByIdIn(pageIds).stream() .sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE))) .toList(); return buildResultPaged(docs, text, pageable, ftsPage.total()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java index 6768991f..1197577c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentLazyLoadingTest.java @@ -131,6 +131,28 @@ class DocumentLazyLoadingTest { .doesNotThrowAnyException(); } + @Test + void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() { + // q + default sort + no other filters → the relevance fast path + // (relevanceSortedPageFromSql), which loads documents by id outside any + // transaction and must still deliver an initialized tags collection. + Person sender = savedPerson("Hans", "FtSender"); + Tag tag = savedTag("FtTag"); + savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag)); + + SearchFilters textOnly = new SearchFilters( + "Walter", null, null, null, null, null, null, null, null, false); + + DocumentSearchResult result = documentService.searchDocuments( + textOnly, null, "DESC", PageRequest.of(0, 10)); + + assertThat(result.totalElements()).isEqualTo(1); + assertThatCode(() -> + result.items().forEach(i -> i.tags().size())) + .doesNotThrowAnyException(); + assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag"); + } + @Test void searchDocuments_senderSort_doesNotThrowLazyInitializationException() { Person sender = savedPerson("Hans", "SsSender"); -- 2.49.1 From e988c3eae78b5c5288e174cc917b714544e3ded5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 23:06:18 +0200 Subject: [PATCH 48/63] =?UTF-8?q?fix(geschichte):=20return=20GeschichteVie?= =?UTF-8?q?w=20from=20create/update=20=E2=80=94=20kill=20write-path=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH /api/geschichten/{id} (save draft, publish) returned the raw entity; with open-in-view false, Jackson serialized the lazy items collection after the transaction closed and every save failed with LazyInitializationException. Write methods now assemble GeschichteView in-transaction, completing the read-model boundary already used by GET — entities no longer cross the controller. Co-Authored-By: Claude Fable 5 --- .../geschichte/GeschichteController.java | 6 +- .../geschichte/GeschichteService.java | 14 +++- .../geschichte/GeschichteControllerTest.java | 36 ++------- .../geschichte/GeschichteHttpTest.java | 75 ++++++++++++++++++- .../GeschichteServiceIntegrationTest.java | 16 ++-- .../geschichte/GeschichteServiceTest.java | 44 +++++------ 6 files changed, 123 insertions(+), 68 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java index 73055218..30b6af59 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java @@ -53,14 +53,14 @@ public class GeschichteController { @PostMapping @RequirePermission(Permission.BLOG_WRITE) - public ResponseEntity create(@RequestBody GeschichteUpdateDTO dto) { - Geschichte created = geschichteService.create(dto); + public ResponseEntity create(@RequestBody GeschichteUpdateDTO dto) { + GeschichteView created = geschichteService.create(dto); return ResponseEntity.status(HttpStatus.CREATED).body(created); } @PatchMapping("/{id}") @RequirePermission(Permission.BLOG_WRITE) - public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) { + public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) { return geschichteService.update(id, dto); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index 9cb957a2..a683b3cc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -127,8 +127,12 @@ public class GeschichteService { // ─── Write API ─────────────────────────────────────────────────────────── + // Write methods return GeschichteView, never the entity: Jackson serializes after + // the transaction closed, where the lazy items collection is a dead proxy. + // The view is assembled in-transaction, so no force-init tricks are needed. + @Transactional - public Geschichte create(GeschichteUpdateDTO dto) { + public GeschichteView create(GeschichteUpdateDTO dto) { requireTitle(dto.getTitle()); Geschichte g = Geschichte.builder() .title(dto.getTitle().trim()) @@ -142,11 +146,12 @@ public class GeschichteService { g.setStatus(GeschichteStatus.PUBLISHED); g.setPublishedAt(LocalDateTime.now()); } - return geschichteRepository.save(g); + Geschichte saved = geschichteRepository.save(g); + return toView(saved, List.of()); } @Transactional - public Geschichte update(UUID id, GeschichteUpdateDTO dto) { + public GeschichteView update(UUID id, GeschichteUpdateDTO dto) { Geschichte g = geschichteRepository.findById(id) .orElseThrow(() -> DomainException.notFound( ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id)); @@ -163,7 +168,8 @@ public class GeschichteService { if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) { applyStatusTransition(g, dto.getStatus()); } - return geschichteRepository.save(g); + Geschichte saved = geschichteRepository.save(g); + return toView(saved, journeyItemService.getItems(id)); } @Transactional diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index f6a3b498..8a139e49 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -150,7 +150,7 @@ class GeschichteControllerTest { void create_returns201_withBlogWrite() throws Exception { UUID id = UUID.randomUUID(); when(geschichteService.create(any(GeschichteUpdateDTO.class))) - .thenReturn(draft(id, "New")); + .thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT)); GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); dto.setTitle("New"); @@ -178,7 +178,7 @@ class GeschichteControllerTest { void update_returns200_withBlogWrite() throws Exception { UUID id = UUID.randomUUID(); when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) - .thenReturn(published(id, "Updated")); + .thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED)); mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -381,35 +381,13 @@ class GeschichteControllerTest { return new JourneyItemView(id, position, null, note); } - private Geschichte published(UUID id, String title) { - return Geschichte.builder() - .id(id) - .title(title) - .body("

x

") - .status(GeschichteStatus.PUBLISHED) - .publishedAt(LocalDateTime.now()) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .persons(new HashSet<>()) - .items(new ArrayList<>()) - .build(); - } - - private Geschichte draft(UUID id, String title) { - return Geschichte.builder() - .id(id) - .title(title) - .status(GeschichteStatus.DRAFT) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .persons(new HashSet<>()) - .items(new ArrayList<>()) - .build(); - } - private GeschichteView viewStub(UUID id, String title) { + return viewStub(id, title, GeschichteStatus.PUBLISHED); + } + + private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) { return new GeschichteView(id, title, "

x

", - GeschichteStatus.PUBLISHED, GeschichteType.STORY, + status, GeschichteType.STORY, null, new HashSet<>(), List.of(), LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java index 025f5296..3c324b32 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java @@ -6,6 +6,8 @@ import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.AppUserRepository; +import org.raddatz.familienarchiv.user.UserGroup; +import org.raddatz.familienarchiv.user.UserGroupRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -16,6 +18,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -28,6 +31,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -49,6 +53,7 @@ class GeschichteHttpTest { @Autowired GeschichteRepository geschichteRepository; @Autowired AppUserRepository appUserRepository; + @Autowired UserGroupRepository userGroupRepository; @Autowired PasswordEncoder passwordEncoder; private RestTemplate http; @@ -63,6 +68,8 @@ class GeschichteHttpTest { baseUrl = "http://localhost:" + port; geschichteRepository.deleteAll(); appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete); + appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete); + userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete); appUserRepository.save(AppUser.builder() .email(WRITER_EMAIL) .password(passwordEncoder.encode(WRITER_PASSWORD)) @@ -184,15 +191,78 @@ class GeschichteHttpTest { assertThat(response.getStatusCode().value()).isEqualTo(404); } + // ─── PATCH /api/geschichten/{id} ───────────────────────────────────────── + + @Test + void update_returns_200_and_serializes_items_open_in_view_false() { + // Canonical guard for the write path: PATCH must not 500 when the response + // is serialized after the service transaction closed. The raw entity carries + // a dead lazy items proxy at that point — the endpoint must answer with a + // view assembled inside the transaction. + AppUser writer = blogWriter(); + Geschichte journey = Geschichte.builder() + .title("Reise vor dem Umbenennen") + .status(GeschichteStatus.DRAFT) + .type(GeschichteType.JOURNEY) + .author(writer) + .items(new ArrayList<>()) + .persons(new HashSet<>()) + .build(); + journey.getItems().add(JourneyItem.builder() + .geschichte(journey).position(1000).note("Prolog").build()); + Geschichte saved = geschichteRepository.save(journey); + + String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD); + ResponseEntity response = http.exchange( + baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH, + new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)), + String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()) + .contains("Reise nach dem Umbenennen") + .contains("Prolog"); + } + // ─── helpers ───────────────────────────────────────────────────────────── + private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de"; + private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2"; + + /** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */ + private AppUser blogWriter() { + UserGroup group = userGroupRepository.save(UserGroup.builder() + .name("HttpTest-BlogWriters") + .permissions(new HashSet<>(Set.of("BLOG_WRITE"))) + .build()); + return appUserRepository.save(AppUser.builder() + .email(BLOG_WRITER_EMAIL) + .password(passwordEncoder.encode(BLOG_WRITER_PASSWORD)) + .groups(new HashSet<>(Set.of(group))) + .build()); + } + + /** Session cookie + double-submit CSRF pair + JSON content type for write requests. */ + private HttpHeaders csrfJsonHeaders(String sessionId) { + String xsrf = UUID.randomUUID().toString(); + HttpHeaders headers = new HttpHeaders(); + headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf); + headers.set("X-XSRF-TOKEN", xsrf); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + private String loginAsWriter() { + return loginAs(WRITER_EMAIL, WRITER_PASSWORD); + } + + private String loginAs(String email, String password) { String xsrf = UUID.randomUUID().toString(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Cookie", "XSRF-TOKEN=" + xsrf); headers.set("X-XSRF-TOKEN", xsrf); - String body = "{\"email\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}"; + String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}"; ResponseEntity resp = http.postForEntity( baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class); return extractFaSessionCookie(resp); @@ -215,7 +285,8 @@ class GeschichteHttpTest { } private RestTemplate noThrowRestTemplate() { - RestTemplate template = new RestTemplate(); + // JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH. + RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory()); template.setErrorHandler(new DefaultResponseErrorHandler() { @Override public boolean hasError(ClientHttpResponse response) throws IOException { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index f882e4f9..5cef25e0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -80,11 +80,11 @@ class GeschichteServiceIntegrationTest { + ""); dto.setPersonIds(List.of(franz.getId())); - Geschichte created = geschichteService.create(dto); + GeschichteView created = geschichteService.create(dto); - assertThat(created.getId()).isNotNull(); - assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT); - assertThat(created.getBody()) + assertThat(created.id()).isNotNull(); + assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT); + assertThat(created.body()) .contains("jeden Sonntag") .doesNotContain(""); - Geschichte saved = geschichteService.create(dto); + GeschichteView saved = geschichteService.create(dto); - assertThat(saved.getBody()) + assertThat(saved.body()) .contains("

safe

") .doesNotContain(""); - Geschichte saved = geschichteService.update(id, dto); + GeschichteView saved = geschichteService.update(id, dto); - assertThat(saved.getBody()).doesNotContain(" diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts index 2aeefc5a..fd1ddcbc 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts @@ -16,7 +16,6 @@ const makeStory = (id: string, title: string, body: string | undefined = '

Bod items: [], author: { id: 'u1', - email: 'marcel@example.com', firstName: 'Marcel', lastName: 'Raddatz', enabled: true, diff --git a/frontend/src/lib/geschichte/utils.test.ts b/frontend/src/lib/geschichte/utils.test.ts index e5f78253..2d5bb6ac 100644 --- a/frontend/src/lib/geschichte/utils.test.ts +++ b/frontend/src/lib/geschichte/utils.test.ts @@ -3,21 +3,19 @@ import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './ describe('formatAuthorName', () => { it('joins firstName and lastName with a space', () => { - expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' })).toBe( - 'Anna Schmidt' - ); + expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt' })).toBe('Anna Schmidt'); }); it('returns firstName alone when lastName is absent', () => { - expect(formatAuthorName({ firstName: 'Anna', email: 'a@x' })).toBe('Anna'); + expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna'); }); it('returns lastName alone when firstName is absent', () => { - expect(formatAuthorName({ lastName: 'Schmidt', email: 'a@x' })).toBe('Schmidt'); + expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt'); }); - it('falls back to email when both names are absent', () => { - expect(formatAuthorName({ email: 'fallback@example.com' })).toBe('fallback@example.com'); + it('falls back to [Unbekannt] when both names are absent', () => { + expect(formatAuthorName({})).toBe('[Unbekannt]'); }); it('returns empty string for null input', () => { diff --git a/frontend/src/lib/geschichte/utils.ts b/frontend/src/lib/geschichte/utils.ts index 36db9d05..495a9be5 100644 --- a/frontend/src/lib/geschichte/utils.ts +++ b/frontend/src/lib/geschichte/utils.ts @@ -1,12 +1,13 @@ import { formatDate } from '$lib/shared/utils/date'; -type AuthorSummary = { firstName?: string; lastName?: string; email: string }; +type AuthorSummary = { firstName?: string; lastName?: string }; type AuthorView = { displayName: string }; export function formatAuthorName(author: AuthorSummary | null | undefined): string { if (!author) return ''; const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim(); - return full || author.email || ''; + // Mirrors the server-side fallback in GeschichteService.toView — email is no longer exposed. + return full || '[Unbekannt]'; } export function formatAuthorDisplayName(author: AuthorView | null | undefined): string { diff --git a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts index 25d8b4cd..4f0a3a11 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -76,7 +76,7 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); }); - it('falls back to author email when no name is set', async () => { + it('renders the server-computed author displayName verbatim', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { diff --git a/frontend/src/routes/geschichten/page.svelte.test.ts b/frontend/src/routes/geschichten/page.svelte.test.ts index 663859b9..0cb6b8a7 100644 --- a/frontend/src/routes/geschichten/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/page.svelte.test.ts @@ -26,7 +26,7 @@ const baseData = (overrides: Record = {}) => ({ title: string; body?: string; publishedAt?: string; - author?: { firstName?: string; lastName?: string; email: string }; + author?: { firstName?: string; lastName?: string }; }>, personFilters: [] as { id?: string; displayName: string }[], documentFilter: null, @@ -127,7 +127,7 @@ describe('geschichten/+ page', () => { title: 'Reise nach Berlin', body: '

Im Jahr 1923...

', publishedAt: '2026-04-15T10:00:00Z', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } + author: { firstName: 'Anna', lastName: 'Schmidt' } } ] }) @@ -139,7 +139,7 @@ describe('geschichten/+ page', () => { .toBeVisible(); }); - it('authorName falls back to email when first/last names are missing', async () => { + it('authorName falls back to [Unbekannt] when first/last names are missing', async () => { render(GeschichtenListPage, { props: { data: baseData({ @@ -147,14 +147,14 @@ describe('geschichten/+ page', () => { { id: 'g1', title: 'Anonym', - author: { email: 'anon@example.com' } + author: {} } ] }) } }); - expect(document.body.textContent).toContain('anon@example.com'); + expect(document.body.textContent).toContain('[Unbekannt]'); }); it('authorName renders empty when author is undefined', async () => { @@ -178,7 +178,7 @@ describe('geschichten/+ page', () => { { id: 'g1', title: 'Draft', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } + author: { firstName: 'Anna', lastName: 'Schmidt' } } ] }) @@ -202,7 +202,7 @@ describe('geschichten/+ page', () => { id: 'g1', title: 'No Body', body: '', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } + author: { firstName: 'Anna', lastName: 'Schmidt' } } ] }) -- 2.49.1 From d34afb2298fb81a5df1b6f8f7999ec81da2716b1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 10 Jun 2026 07:28:09 +0200 Subject: [PATCH 56/63] =?UTF-8?q?fix(geschichte):=20store=20JOURNEY=20intr?= =?UTF-8?q?os=20as=20plain=20text=20=E2=80=94=20no=20HTML=20entity=20encod?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OWASP sanitizer entity-encodes ('&' → '&') while JourneyReader renders the intro via Svelte text interpolation — a curator typing 'Müller & Söhne' saw 'Müller & Söhne', re-encoded cumulatively on every editor round-trip. JOURNEY bodies now bypass the sanitizer (safe: the reader never uses {@html}); STORY bodies keep the full allow-list sanitization. This makes the code match the PR's documented design note. Co-Authored-By: Claude Fable 5 --- .../geschichte/GeschichteService.java | 17 +++++- .../geschichte/GeschichteServiceTest.java | 57 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index fb1164ea..eb71ec19 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -134,11 +134,12 @@ public class GeschichteService { @Transactional public GeschichteView create(GeschichteUpdateDTO dto) { requireTitle(dto.getTitle()); + GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY; Geschichte g = Geschichte.builder() .title(dto.getTitle().trim()) - .body(sanitize(dto.getBody())) + .body(bodyForType(type, dto.getBody())) .status(GeschichteStatus.DRAFT) - .type(dto.getType() != null ? dto.getType() : GeschichteType.STORY) + .type(type) .author(currentUser()) .persons(resolvePersons(dto.getPersonIds())) .build(); @@ -164,7 +165,7 @@ public class GeschichteService { g.setTitle(dto.getTitle().trim()); } if (dto.getBody() != null) { - g.setBody(sanitize(dto.getBody())); + g.setBody(bodyForType(g.getType(), dto.getBody())); } if (dto.getPersonIds() != null) { g.setPersons(resolvePersons(dto.getPersonIds())); @@ -203,6 +204,16 @@ public class GeschichteService { } } + /** + * STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer. + * JOURNEY intros are plain text: the reader renders them via Svelte text + * interpolation (never {@code {@html}}), so entity-encoding them here would + * corrupt content ("&" → "&") and re-encode on every editor round-trip. + */ + private String bodyForType(GeschichteType type, String body) { + return type == GeschichteType.JOURNEY ? body : sanitize(body); + } + private String sanitize(String body) { if (body == null) return null; return BODY_SANITIZER.sanitize(body); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index a51408a4..37e00712 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -420,6 +420,63 @@ class GeschichteServiceTest { assertThat(saved.type()).isEqualTo(GeschichteType.STORY); } + @Test + void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() { + // The journey intro is plain text: JourneyReader renders it via Svelte text + // interpolation (never {@html}), so the OWASP sanitizer's entity encoding + // would corrupt real content ("Müller & Söhne" → "Müller & Söhne") and + // re-encode cumulatively on every editor round-trip. + authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("Winterbriefe"); + dto.setType(GeschichteType.JOURNEY); + dto.setBody("Müller & Söhne, Temperatur < 0"); + + GeschichteView saved = geschichteService.create(dto); + + assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0"); + } + + @Test + void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() { + authenticateAs(writer, Permission.BLOG_WRITE); + UUID id = UUID.randomUUID(); + Geschichte existing = draft(id); + existing.setType(GeschichteType.JOURNEY); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing)); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setBody("Temperatur < 0 & Schnee"); + + GeschichteView saved = geschichteService.update(id, dto); + + assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee"); + } + + @Test + void update_still_sanitizes_STORY_body() { + authenticateAs(writer, Permission.BLOG_WRITE); + UUID id = UUID.randomUUID(); + Geschichte existing = draft(id); + existing.setType(GeschichteType.STORY); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing)); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setBody("

ok

"); + + GeschichteView saved = geschichteService.update(id, dto); + + assertThat(saved.body()).doesNotContain(" -
picker.close()} class="relative"> @@ -53,34 +90,43 @@ function handleSelect(doc: DocumentOption) { role="combobox" autocomplete="off" aria-label={placeholder} - aria-expanded={picker.isOpen && picker.results.length > 0} + aria-expanded={picker.isOpen} aria-controls={listboxId} aria-autocomplete="list" + aria-activedescendant={activeOptionId} placeholder={placeholder} value={inputValue} oninput={handleInput} + onkeydown={handleKeydown} class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" /> - {#if picker.isOpen && (picker.results.length > 0 || picker.loading || picker.error)} + {#if picker.isOpen}
    {#if picker.loading}
  • {m.comp_multiselect_loading()}
  • {:else if picker.error} + {:else if picker.results.length === 0} +
  • {m.comp_typeahead_no_results()}
  • {:else} - {#each picker.results as doc (doc.id)} + {#each picker.results as doc, i (doc.id)} {@const disabled = alreadyAddedIds.has(doc.id!)}
  • handleSelect(doc)} - onkeydown={(e) => e.key === 'Enter' && handleSelect(doc)} + onkeydown={(e) => handleOptionKeydown(e, doc)} tabindex={disabled ? -1 : 0} class={[ 'px-3 py-2 text-ink select-none', + i === picker.activeIndex ? 'bg-muted' : '', disabled ? 'cursor-default opacity-50' : 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none' diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts index 67ebdaef..046d5767 100644 --- a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts @@ -95,6 +95,89 @@ describe('DocumentPickerDropdown — selection', () => { }); }); +describe('DocumentPickerDropdown — keyboard navigation', () => { + it('selects the first option via ArrowDown then Enter', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); + + render(DocumentPickerDropdown, { onSelect }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); + }); + + it('does not select an aria-disabled option on Enter', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { + alreadyAddedIds: new Set(['d1']), + onSelect + }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('closes the dropdown on Escape', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await expect.element(page.getByRole('listbox')).toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + }); + + it('points aria-activedescendant at the active option', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + const input = page.getByRole('combobox'); + await userEvent.fill(input, 'Brief'); + await waitForDebounce(); + + expect(input.element().getAttribute('aria-activedescendant')).toBeNull(); + + await userEvent.keyboard('{ArrowDown}'); + + const activeId = input.element().getAttribute('aria-activedescendant'); + expect(activeId).toMatch(/-option-0$/); + const firstOption = page + .getByText(/Brief von Eugenie/i) + .element() + .closest('li')!; + expect(firstOption.id).toBe(activeId); + expect(firstOption.getAttribute('aria-selected')).toBe('true'); + }); +}); + +describe('DocumentPickerDropdown — no results', () => { + it('shows a non-interactive no-results row when the search returns zero hits', async () => { + mockSearchResponse([]); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + + await userEvent.fill(page.getByRole('combobox'), 'xyz'); + await waitForDebounce(); + + await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument(); + }); +}); + describe('DocumentPickerDropdown — search failure', () => { it('shows an error message when the search request fails instead of vanishing', async () => { // 500 from /api/documents/search — must surface, not render as "no results" diff --git a/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts b/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts index 7cdd05e9..89794289 100644 --- a/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts +++ b/frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts @@ -106,6 +106,17 @@ describe('createTypeahead', () => { errorSpy.mockRestore(); }); + it('sets loading immediately on setQuery so empty results read as pending, not "no results"', async () => { + const fetchUrl = vi.fn().mockResolvedValue([]); + const ta = createTypeahead({ fetchUrl, debounceMs: 300 }); + ta.setQuery('foo'); + // During the debounce window no fetch has run yet — callers must be able to + // distinguish "still searching" from "searched, zero hits". + expect(ta.loading).toBe(true); + await vi.advanceTimersByTimeAsync(300); + expect(ta.loading).toBe(false); + }); + it('setActiveIndex updates activeIndex', () => { const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) }); expect(ta.activeIndex).toBe(-1); diff --git a/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts b/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts index b132da2b..4f51abbe 100644 --- a/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts +++ b/frontend/src/lib/shared/hooks/useTypeahead.svelte.ts @@ -19,9 +19,11 @@ export function createTypeahead(options: Options) { function setQuery(q: string) { query = q; isOpen = true; + // Set loading before the debounce fires so callers can distinguish + // "still searching" from "searched, zero hits" during the debounce window. + loading = true; clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { - loading = true; error = false; try { results = await fetchUrl(q); -- 2.49.1 From f10b0cb73ed67034ba306b264873b78c195573e6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 10 Jun 2026 07:55:12 +0200 Subject: [PATCH 61/63] =?UTF-8?q?fix(journey):=20editor=20review=20round?= =?UTF-8?q?=20=E2=80=94=20labels,=20errors,=20pending=20state,=20a11y,=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the remaining #792 review blockers and concerns in the journey editor cluster: - Interlude rows show 'Zwischentext' (dedicated key), not the add-button text - All four mutation handlers route the backend ErrorCode through getErrorMessage (a 409 duplicate no longer says 'bitte Seite neu laden') and console.error their failures so client-side errors leave a trace - Remove implements the spec'd pending state: row stays dimmed with an aria-live 'wird entfernt…' until the DELETE resolves; failure keeps the row - Move announcements fire after the reorder resolves (no false 'verschoben') - Touch targets ≥44px (remove ×, note links, create submit); focus moves to the new row after add, to a sensible neighbor after remove, back to × on confirm-cancel; drag handle is pointer-only; title/intro get aria-labels; publish-disabled reason is a visible hint, not a title tooltip - Amber warning styles use new --color-warning-* tokens with dark remaps - Blocked interlude-clear restores the draft instead of showing phantom text - useBlockDragDrop moves to $lib/shared/hooks — geschichte no longer imports another domain's internals - Test hardening: reorder-failure rollback (non-ok + reject), publish/ unpublish/empty-warning surface, destructive confirm path, maxlength assertions, JourneyCreate failure path, edit-page STORY/JOURNEY branch, fixture factory, m.* assertions, all fixed sleeps replaced with polling 67 component tests green across 6 spec files; transcription consumer of the moved hook re-verified (30 green). Co-Authored-By: Claude Fable 5 --- .../TranscriptionEditView.svelte | 2 +- .../src/lib/geschichte/JourneyAddBar.svelte | 1 + .../geschichte/JourneyAddBar.svelte.spec.ts | 31 +- .../src/lib/geschichte/JourneyEditor.svelte | 179 ++++-- .../geschichte/JourneyEditor.svelte.spec.ts | 553 ++++++++++++------ .../src/lib/geschichte/JourneyItemRow.svelte | 63 +- .../geschichte/JourneyItemRow.svelte.spec.ts | 129 +++- .../hooks}/useBlockDragDrop.svelte.test.ts | 0 .../hooks}/useBlockDragDrop.svelte.ts | 0 .../geschichten/[id]/edit/page.svelte.test.ts | 56 +- .../geschichten/new/JourneyCreate.svelte | 3 +- .../new/JourneyCreate.svelte.spec.ts | 73 +++ frontend/src/routes/layout.css | 21 + 13 files changed, 843 insertions(+), 268 deletions(-) rename frontend/src/lib/{document/transcription => shared/hooks}/useBlockDragDrop.svelte.test.ts (100%) rename frontend/src/lib/{document/transcription => shared/hooks}/useBlockDragDrop.svelte.ts (100%) create mode 100644 frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte index 4d764a61..a0f00812 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte @@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte'; import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte'; import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; -import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; +import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte'; import { csrfFetch } from '$lib/shared/cookies'; type Props = { diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte b/frontend/src/lib/geschichte/JourneyAddBar.svelte index c9af4b61..c096e669 100644 --- a/frontend/src/lib/geschichte/JourneyAddBar.svelte +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte @@ -40,6 +40,7 @@ function handleInterludeCancel() {
    - +
    - {#if showRemoveConfirm} + {#if pendingRemove} + + {m.journey_item_pending_remove()} + + {:else if showRemoveConfirm}
    {m.journey_remove_confirm()} @@ -203,7 +240,7 @@ function handleRemoveClick() { onclick={() => { showNote = true; }} - class="font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" > {m.journey_note_add()} diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts index c22c874a..685f505f 100644 --- a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts @@ -1,12 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; import JourneyItemRow from './JourneyItemRow.svelte'; const docItem = (overrides: Partial<{ note: string }> = {}) => ({ id: 'item-1', position: 0, - document: { id: 'doc-1', title: 'Brief von Karl', datePrecision: 'DAY' as const }, + document: { + id: 'doc-1', + title: 'Brief von Karl', + datePrecision: 'DAY' as const, + receiverCount: 0 + }, ...overrides }); @@ -28,11 +34,32 @@ const defaultProps = (overrides = {}) => ({ afterEach(() => cleanup()); +describe('JourneyItemRow — interlude label', () => { + it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps() }); + + await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument(); + await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument(); + }); + + it('uses "Zwischentext" in the move button aria-labels', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) }); + + await expect + .element( + page.getByRole('button', { + name: m.journey_move_up({ title: m.journey_interlude_label() }) + }) + ) + .toBeInTheDocument(); + }); +}); + describe('JourneyItemRow — note textarea', () => { it('opens note textarea on "Notiz hinzufügen" click', async () => { render(JourneyItemRow, { item: docItem(), ...defaultProps() }); - await userEvent.click(page.getByText('Notiz hinzufügen')); + await userEvent.click(page.getByText(m.journey_note_add())); await expect .element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })) @@ -43,13 +70,20 @@ describe('JourneyItemRow — note textarea', () => { const onNotePatch = vi.fn().mockResolvedValue(undefined); render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); - await userEvent.click(page.getByText('Notiz hinzufügen')); + await userEvent.click(page.getByText(m.journey_note_add())); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); await userEvent.fill(textarea, 'Eine neue Notiz'); await textarea.element().dispatchEvent(new FocusEvent('blur')); expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz'); }); + + it('limits the note textarea to 2000 characters', async () => { + render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await expect.element(textarea).toHaveAttribute('maxlength', '2000'); + }); }); describe('JourneyItemRow — note error state', () => { @@ -57,7 +91,7 @@ describe('JourneyItemRow — note error state', () => { const onNotePatch = vi.fn().mockRejectedValue(new Error('server error')); render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); - await userEvent.click(page.getByText('Notiz hinzufügen')); + await userEvent.click(page.getByText(m.journey_note_add())); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); await userEvent.fill(textarea, 'Eine Notiz'); await textarea.element().dispatchEvent(new FocusEvent('blur')); @@ -74,8 +108,7 @@ describe('JourneyItemRow — note remove error state', () => { ...defaultProps({ onNotePatch }) }); - await userEvent.click(page.getByText('Notiz entfernen')); - await new Promise((r) => setTimeout(r, 50)); + await userEvent.click(page.getByText(m.journey_note_remove())); // textarea should be visible again (showNote restored) await expect @@ -95,7 +128,7 @@ describe('JourneyItemRow — interlude rules', () => { .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) .toBeInTheDocument(); // But "Notiz entfernen" must be absent - await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument(); + await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument(); }); it('blocks saving empty text on interlude note blur', async () => { @@ -111,6 +144,19 @@ describe('JourneyItemRow — interlude rules', () => { expect(onNotePatch).not.toHaveBeenCalled(); }); + + it('restores the original note text after a blocked empty-clear blur', async () => { + render(JourneyItemRow, { + item: interludeItem('original text'), + ...defaultProps() + }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await userEvent.clear(textarea); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + await expect.element(textarea).toHaveValue('original text'); + }); }); describe('JourneyItemRow — remove confirm', () => { @@ -121,10 +167,25 @@ describe('JourneyItemRow — remove confirm', () => { }); // Click remove (x button) - await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); - await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument(); - await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument(); + await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: m.journey_remove_confirm_yes() })) + .toBeInTheDocument(); + }); + + it('clicking Bestätigen invokes onRemove (destructive path)', async () => { + const onRemove = vi.fn(); + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps({ onRemove }) + }); + + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() })); + + expect(onRemove).toHaveBeenCalledTimes(1); }); it('confirm cancel restores remove button without calling onRemove', async () => { @@ -134,13 +195,55 @@ describe('JourneyItemRow — remove confirm', () => { ...defaultProps({ onRemove }) }); - await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' })); - await userEvent.click(page.getByRole('button', { name: 'Abbrechen' })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() })); expect(onRemove).not.toHaveBeenCalled(); // The remove button should be back await expect - .element(page.getByRole('button', { name: 'Eintrag entfernen' })) + .element(page.getByRole('button', { name: m.journey_remove_item_aria() })) .toBeInTheDocument(); }); + + it('confirm cancel returns keyboard focus to the row remove button', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Notiz' }), + ...defaultProps() + }); + + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() })); + + await vi.waitFor(() => { + const removeBtn = page.getByRole('button', { name: m.journey_remove_item_aria() }).element(); + expect(document.activeElement).toBe(removeBtn); + }); + }); +}); + +describe('JourneyItemRow — pending remove state', () => { + it('renders dimmed with the pending text and without a remove button', async () => { + render(JourneyItemRow, { + item: docItem(), + ...defaultProps({ pendingRemove: true }) + }); + + await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: m.journey_remove_item_aria() })) + .not.toBeInTheDocument(); + + const root = document.querySelector('[data-block-id="item-1"]')!; + expect(root.className).toContain('opacity-60'); + }); +}); + +describe('JourneyItemRow — drag handle', () => { + it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => { + render(JourneyItemRow, { item: docItem(), ...defaultProps() }); + + const handle = document.querySelector('[data-drag-handle]')!; + expect(handle.getAttribute('tabindex')).toBe('-1'); + expect(handle.getAttribute('aria-hidden')).toBe('true'); + }); }); diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts similarity index 100% rename from frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts rename to frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts similarity index 100% rename from frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts rename to frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts diff --git a/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts index 5376edb6..82098c52 100644 --- a/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; vi.mock('$app/navigation', () => ({ beforeNavigate: () => {}, @@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte'); afterEach(cleanup); const baseData = (overrides: Record = {}) => ({ + user: undefined, + canWrite: true, + canAnnotate: false, + canBlogWrite: true, geschichte: { id: 'g1', title: 'Die Reise nach Berlin', body: '

    Im Jahr 1923...

    ', status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED', + type: 'STORY' as 'STORY' | 'JOURNEY', persons: [], - documents: [] + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' }, ...overrides }); @@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => { const inputs = document.querySelectorAll('input, textarea, [contenteditable]'); expect(inputs.length).toBeGreaterThan(0); }); + + it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => { + render(GeschichtenEditPage, { + props: { + data: baseData({ + geschichte: { + id: 'g1', + title: 'Die Reise nach Berlin', + body: '', + status: 'DRAFT' as const, + type: 'JOURNEY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + }) + } + }); + + await expect.element(page.getByText(m.journey_add_document())).toBeVisible(); + expect(document.querySelector('[role="toolbar"]')).toBeNull(); + }); + + it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => { + render(GeschichtenEditPage, { + props: { + data: baseData({ + geschichte: { + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

    Im Jahr 1923...

    ', + status: 'DRAFT' as const, + type: 'STORY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + }) + } + }); + + await expect.element(page.getByRole('toolbar')).toBeVisible(); + await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/geschichten/new/JourneyCreate.svelte b/frontend/src/routes/geschichten/new/JourneyCreate.svelte index f6f54395..cedd7d8b 100644 --- a/frontend/src/routes/geschichten/new/JourneyCreate.svelte +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte @@ -60,6 +60,7 @@ async function handleSubmit(e: SubmitEvent) { bind:value={title} onblur={() => (titleTouched = true)} placeholder={m.geschichte_editor_title_placeholder()} + aria-label={m.journey_title_aria_label()} aria-invalid={showTitleError} class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError ? 'border-danger' @@ -74,7 +75,7 @@ async function handleSubmit(e: SubmitEvent) { diff --git a/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts b/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts new file mode 100644 index 00000000..30ee0549 --- /dev/null +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import { getErrorMessage } from '$lib/shared/errors'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +const { default: JourneyCreate } = await import('./JourneyCreate.svelte'); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('JourneyCreate — failure path', () => { + it('renders the mapped error message when POST /api/geschichten fails with a code', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' }) + }) + ); + + render(JourneyCreate, {}); + + await userEvent.fill( + page.getByRole('textbox', { name: m.journey_title_aria_label() }), + 'Meine Lesereise' + ); + await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() })); + + const alert = page.getByRole('alert'); + await expect.element(alert).toBeInTheDocument(); + await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR')); + }); + + it('navigates to the edit page on success', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ id: 'g-new' }) + }) + ); + + render(JourneyCreate, {}); + + await userEvent.fill( + page.getByRole('textbox', { name: m.journey_title_aria_label() }), + 'Meine Lesereise' + ); + await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() })); + + await vi.waitFor(() => { + expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit'); + }); + }); + + it('has an accessible label on the title input', async () => { + vi.stubGlobal('fetch', vi.fn()); + render(JourneyCreate, {}); + + await expect + .element(page.getByRole('textbox', { name: m.journey_title_aria_label() })) + .toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index a4af174c..dfd048ff 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -77,6 +77,11 @@ --color-warning: #b45309; --color-warning-fg: #ffffff; + /* Warning surface — amber banner (bg/border/text), mode-aware */ + --color-warning-bg: var(--c-warning-bg); + --color-warning-border: var(--c-warning-border); + --color-warning-text: var(--c-warning-text); + /* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */ --color-journey-tint: var(--c-journey-bg); --color-journey: var(--c-journey-text); @@ -149,6 +154,11 @@ --c-interlude-border: #a1dcd8; --c-interlude-label: #4b5563; + /* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */ + --c-warning-bg: #fffbeb; + --c-warning-border: #fcd34d; + --c-warning-text: #92400e; + /* Tag color tokens — decorative dot colors on tag chips */ --c-tag-sage: #5a8a6a; --c-tag-sienna: #a0522d; @@ -278,6 +288,12 @@ --c-interlude-bg: #151c22; --c-interlude-border: #00c7b1; --c-interlude-label: #8b97a5; + + /* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓ + KEEP IN SYNC with :root[data-theme='dark'] */ + --c-warning-bg: #2a2113; + --c-warning-border: #6d5417; + --c-warning-text: #fbd38d; } } @@ -363,6 +379,11 @@ --c-interlude-bg: #151c22; --c-interlude-border: #00c7b1; --c-interlude-label: #8b97a5; + + /* Warning surface — KEEP IN SYNC with the @media block above */ + --c-warning-bg: #2a2113; + --c-warning-border: #6d5417; + --c-warning-text: #fbd38d; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */ -- 2.49.1 From 4374f75d3cff1ccf33e7590b5acf345f7869cebc Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 10 Jun 2026 07:57:31 +0200 Subject: [PATCH 62/63] =?UTF-8?q?chore(types):=20regen=20api.ts=20?= =?UTF-8?q?=E2=80=94=20AuthorSummary=20email=20removed=20from=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- frontend/src/lib/generated/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 726682be..5b49d7d7 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2511,7 +2511,6 @@ export interface components { AuthorSummary: { firstName?: string; lastName?: string; - email: string; }; GeschichteSummary: { body?: string; -- 2.49.1 From a65a55448ef03324079a4c9b2ceee9316f9e849e Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 10 Jun 2026 08:37:31 +0200 Subject: [PATCH 63/63] test: align unit stubs and fixtures with the review-round changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught three spots the targeted local runs missed: the relevance-path unit tests still stubbed findAllById (the path now calls findByIdIn — the batchMetadata stubs legitimately keep findAllById), the second GeschichtenCard test file still expected the removed email fallback, and the AND/OR-toggle describe lacked the wait-for-slide-transition guard its sibling describe documents — the flake that failed run 2208. Co-Authored-By: Claude Fable 5 --- .../raddatz/familienarchiv/document/DocumentService.java | 2 +- .../familienarchiv/document/DocumentServiceSortTest.java | 8 ++++---- .../familienarchiv/document/DocumentServiceTest.java | 4 ++-- .../src/lib/geschichte/GeschichtenCard.svelte.test.ts | 7 +++---- frontend/src/routes/SearchFilterBar.svelte.spec.ts | 4 ++++ 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 3c9814ec..011ab3b1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -851,7 +851,7 @@ public class DocumentService { FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit)); if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of()); - // Preserve ts_rank order from SQL across the JPA findAllById call. + // Preserve ts_rank order from SQL across the JPA findByIdIn call. Map rankMap = new HashMap<>(); List pageIds = new ArrayList<>(); for (int i = 0; i < ftsPage.hits().size(); i++) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java index bbc058ac..e432c71b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceSortTest.java @@ -81,7 +81,7 @@ class DocumentServiceSortTest { UUID id1 = UUID.randomUUID(); List ftsRows = ftsRows(id1, 0.5d, 1L); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); - when(documentRepository.findAllById(any())) + when(documentRepository.findByIdIn(any())) .thenReturn(List.of(doc(id1))); documentService.searchDocuments( @@ -101,7 +101,7 @@ class DocumentServiceSortTest { ftsRows.add(new Object[]{id1, 0.8d, 2L}); ftsRows.add(new Object[]{id2, 0.3d, 2L}); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); - when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA + when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA DocumentSearchResult result = documentService.searchDocuments( new SearchFilters("Brief", null, null, null, null, null, null, null, null, false), @@ -119,7 +119,7 @@ class DocumentServiceSortTest { ftsRows.add(new Object[]{id1, 0.8d, 2L}); ftsRows.add(new Object[]{id2, 0.3d, 2L}); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); - when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); + when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); DocumentSearchResult result = documentService.searchDocuments( new SearchFilters("Brief", null, null, null, null, null, null, null, null, false), @@ -153,7 +153,7 @@ class DocumentServiceSortTest { List ftsRows = new ArrayList<>(); ftsRows.add(new Object[]{stringId, 0.5d, 1L}); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); - when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId))); + when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId))); DocumentSearchResult result = documentService.searchDocuments( new SearchFilters("Brief", null, null, null, null, null, null, null, null, false), diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 023b2003..65fbb21f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -2166,7 +2166,7 @@ class DocumentServiceTest { List ftsRows = new java.util.ArrayList<>(); ftsRows.add(new Object[]{docId, 0.5d, 1L}); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); - when(documentRepository.findAllById(any())).thenReturn(List.of(doc)); + when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc)); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( @@ -2202,7 +2202,7 @@ class DocumentServiceTest { List snippetFtsRows = new java.util.ArrayList<>(); snippetFtsRows.add(new Object[]{docId, 0.5d, 1L}); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows); - when(documentRepository.findAllById(any())).thenReturn(List.of(doc)); + when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc)); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts index 9b46a7d8..34611cd3 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts @@ -17,7 +17,6 @@ const makeGeschichte = (overrides: Record = {}): GeschichteSumm type: 'STORY' as const, publishedAt: '2026-04-15T10:00:00Z', author: { - email: 'a@b', firstName: 'Anna', lastName: 'Schmidt' }, @@ -103,17 +102,17 @@ describe('GeschichtenCard', () => { await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); }); - it('falls back to author email when no name', async () => { + it('falls back to [Unbekannt] when no name', async () => { render(GeschichtenCard, { props: baseProps({ geschichten: [ makeGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@x' } + author: { firstName: undefined, lastName: undefined } }) ] }) }); - await expect.element(page.getByText(/fallback@x/)).toBeVisible(); + await expect.element(page.getByText('[Unbekannt]')).toBeVisible(); }); }); diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 723e5858..db0e1a8e 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -47,6 +47,10 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => { async function openAdvanced() { const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); await filterBtn.click(); + // Wait for slide transition to finish before interacting with contents — + // clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js + // (same guard as the undated-only describe below; this block flaked in CI run 2208). + await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible(); } it('hides AND/OR toggle when fewer than 2 tags are selected', async () => { -- 2.49.1