Commit Graph

143 Commits

Author SHA1 Message Date
Marcel
4603e335fd feat(frontend): add JOURNEY_ITEM error codes, i18n keys, regen API types
Adds JOURNEY_ITEM_NOT_FOUND and JOURNEY_ITEM_POSITION_CONFLICT to the frontend
ErrorCode union and getErrorMessage() switch. Adds de/en/es translations.
Regenerates api.ts from the current OpenAPI spec (needs a second run once the
backend is restarted with the new endpoints compiled in).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:04:51 +02:00
d650b6c066 refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
## Summary

- Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters
- Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes)
- Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching)
- Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal
- Strips smart-mode logic from SearchFilterBar and documents/+page.svelte
- Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es)
- Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files
- Removes Ollama/NLP Prometheus scrape job and Grafana dashboard
- Deletes ADRs 028 (×2), 034, 035

## Test plan

- [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS
- [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server`
- [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src`
- [ ] `docker compose config` validates both compose files
- [ ] Search page loads, filter bar works, no smart-mode toggle visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #772
2026-06-08 10:57:00 +02:00
Marcel
005265b5a8 feat(search): add NL search error codes and i18n strings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 15:36:13 +02:00
Marcel
378da60ae8 test(mention): lock deleted-person graceful-degradation contract (#684)
Strengthen one renderTranscriptionBody case into the AC-6 contract: a
@DisplayName with an empty mentionedPersons array (the deleted-person case
V71 produces) must render as plain readable text with no <a>, person-mention
class, data-person-id, or href. Guards against a future renderer refactor
silently reintroducing the dead-link-on-deleted-person degradation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:46 +02:00
Marcel
8c198f22be polish(transcribe): review nits — kbd size, focus ring, guard, action doc (#327)
Review follow-up (Leonie, Felix, Markus): bump cheatsheet key caps to text-sm
for the 60+ audience, add a focus-visible ring to the close button, simplify
the draw-hint guard to {#if drawArmed} (the $effect already clears it outside
edit mode), and document why the transcribeShortcuts action ignores its node
and binds to window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
f07527158c fix(transcribe): hide the "?" hint on touch-only devices (#327)
Review follow-up (Requirements Engineer, Leonie) — closes the unmet
acceptance row. The coach card's "press ?" tip rendered unconditionally, so
a touch-only tablet transcriber (no hardware keyboard) was told to press a
key they don't have. The hint is now gated behind a fine-pointer media
query ([@media(pointer:coarse)]:hidden); the cheatsheet itself only opens
via the "?" key, so it already never surfaces without a keyboard. Also bumps
the key cap from 11px to text-xs for the 60+ audience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
0336d07980 feat(transcribe): surface the "?" shortcut tip in the coach card (#327)
Adds a secondary keyboard hint to the existing coach footer row pointing
transcribers at the "?" cheatsheet, with a semantic <kbd>. Cross-references
the shortcuts introduced for the empty-state coach (#320).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
1b9707c6cd feat(transcribe): add transcribeShortcuts keyboard action (#327)
Single-owner window keydown action for the Transcribe panel: j/k region
nav, e mode toggle, n draw (edit only), t training mark, Delete, ? cheat-
sheet, and the Esc precedence ladder (cheatsheet → editable no-op → close
panel). Pure input-to-callback translator with a focus guard that exempts
only "?"; removes its listener on destroy. 20 unit tests cover every key,
the panel/focus guards, the Esc matrix, and teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00
Marcel
4db2e97490 revert(test): abandon shared-mock dedup — infeasible in vitest browser mode
CI proved cross-file sharing of a virtual-module mock body cannot work in
@vitest/browser-playwright 4.1.6: the static-import spread fails the hoist
("no top level variables"), and the await-vi.hoisted-import form fails to
parse ("Unexpected identifier 'vi'"). vi.hoisted has the same hoist
constraint as vi.mock, so there is no way to thread an external module's
body into the factory here.

Reverts Phase 1: restores the 4 $app/forms/$app/navigation specs to their
inline factories, inlines NotificationBell.spec's forms stub, deletes the
src/__mocks__/$app/* modules and the $mocks alias (vite, vitest-coverage,
kit). The no-factory-ban meta-test stays (no-factory vi.mock is still
banned). ADR-012 amended to record the infeasibility. Everything else
($app/state migration, confirm context-inject, notification refactor, the
pin, the meta-test) is unaffected. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
25b23843c9 fix(test): load shared mocks via vi.hoisted, not a static import
CI caught that vi.mock('$app/forms', () => ({ ...formsMock })) with a
static `import * as formsMock` fails: vitest hoists vi.mock above the
import, so the factory references an uninitialised binding
("no top level variables inside"). Load the shared mock module via
`const formsMock = await vi.hoisted(() => import('$mocks/...'))` instead —
the factory may reference a vi.hoisted binding, and the dynamic import runs
at collection time (not in the lazily-invoked factory), so it stays clear
of ADR-012's birpc race and the no-async-mock-factories guard. Applies to
all 5 shared-mock consumers ($app/forms x4, $app/navigation x1). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
abe860bec7 test(hooks): migrate useUnsavedWarning spec to shared $app/navigation mock
Replaces the local beforeNavigate-capture plumbing and simulateNavigate
helper with the shared $mocks/$app/navigation module via a sync factory.
The per-test reset now comes from the shared module's embedded beforeEach.
Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
9c1eb7608b fix(transcription): harden re-edit pencil hit-testing + disable sync (#628 review)
Addresses the clean-agent review of PR #717:

- C1: the hidden pencil was opacity-0 only, which still hit-tests; its 44px box
  overhangs adjacent text, so a click in the gap between two mentions could land
  on the invisible button and spuriously open the dropdown (AC-8 hole). Add
  pointer-events-none while hidden, re-enabled with the opacity reveal on
  hover/focus.
- C2/N1: editor.setEditable() emits "update", not a ProseMirror transaction, so
  the NodeView's 'transaction' listener missed a mid-session disable flip (stale
  aria-disabled/tabindex; the comment was wrong). Listen on 'update' instead —
  which also skips selection-only changes, so it fires far less often.
- N2: track the node across update() so the pencil opens with the live
  displayName (hardening; relink only swaps personId today).

Tests: structural guard that the hidden pencil is pointer-events-none + reveals,
and a mid-session disable-flip test (fixture gains an onReady setDisabled hook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
9bba5e4a7a feat(transcription): announce re-edit context via the existing live region (#628)
Passes editingDisplayName into MentionDropdown; the persistent aria-live region
announces person_mention_editing_announce({displayName}) on re-edit open and
falls back to the prompt/empty/count copy once the user edits or results arrive.
Routed through the SAME sr-only region as the result count — no second live
region (avoids the double-announce bug Leonie S-2 fixed). Fresh-@ passes an
empty editingDisplayName, so its announcements are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
751a48b22c test(transcription): AC-7 disabled, AC-8 no-mention, security clip/provenance (#628)
- AC-7: disabled editor → pencil is disabled + aria-disabled + tabindex -1, and
  neither keyboard nor pointer activation mounts a dropdown (WCAG 2.1.1, not just
  pointer-events-none).
- AC-8: plain text shows no pencil/dropdown; two adjacent mentions each keep one
  pencil with no spurious gap pencil and no auto-open; a doc-start mention still
  renders its pencil.
- Security: an oversized stored displayName clips the search query to 100 chars
  while the preserved node text stays full-length; re-link sources personId
  solely from the picked Person (p-anna), never the reflected/clipped text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
58a30a6e2e test(transcription): AC-6 single-dropdown invariant + stale-fetch guard (#628)
Locks in the single-owner controller guarantees: pencil→pencil, fresh-@→pencil
and pencil→fresh-@ all leave exactly one dropdown open; the request-token bump
on open discards a superseded open's in-flight fetch (open A → open B → A
resolves, deterministic, no sleeps). Plus a #380 AC-1 regression guard that the
fresh-@ path still inserts the typed text as displayName after the controller
refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
2430092e43 feat(transcription): dismiss + keyboard-operate the re-edit dropdown (#628 AC-4/AC-9)
Adds a visible × dismiss control to MentionDropdown (shared by the fresh-@ and
re-edit paths) and, for the re-edit path which has no Tiptap suggestion plugin
to forward keys, focuses the search input on open and handles its own keyboard:
Escape dismisses (AC-4), Arrow/Enter reuse the exported selection logic so the
dropdown is navigable on its own (AC-9 parity with the fresh-@ dropdown).

Both close paths (Escape + ×) leave the mention node attrs + text byte-identical
(AC-4) — close() never touches the document. Controller wires ondismiss=close
(+refocus editor) and focusOnMount only for the re-edit open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
4a93543645 feat(transcription): re-edit @mention via a pencil affordance (#628)
Hosts each mention as a Tiptap NodeView (mentionNodeView.ts) that renders the
@displayName token (textContent — never innerHTML) plus a contenteditable=false
pencil button in a fixed-width slot, revealed on whole-token hover and keyboard
focus (instant opacity swap, no reflow). Activating the pencil (click or Enter/
Space) opens the single mention dropdown via the controller, anchored at the
token and pre-filled with the stored displayName.

commitRelink swaps ONLY personId in place via setNodeMarkup, sourcing the id
solely from the selected Person — the stored displayName is preserved by
construction (AC-3), even after the search input is edited (AC-5, the #380 AC-1
invariant). renderHTML/renderText stay for serialization + clipboard.

AC-1/AC-2/AC-3/AC-5 + serializer round-trip covered by browser tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
b453c13bae refactor(transcription): lift @mention dropdown lifecycle into a single controller
Pulls mountedDropdown / requestId / debouncedSearch / dropdownState ownership
out of Tiptap's suggestion.render() closure into one createMentionController().
render() becomes a thin adapter: onStart→open, onUpdate→update, onExit→close.

This is the single-owner structure #628 needs for the AC-6 single-dropdown
invariant — the upcoming pencil re-edit affordance opens via the same
controller.open() instead of racing the suggestion plugin over module state.
open() now also bumps the request token so an open-A→open-B sequence discards
A's in-flight fetch (preserved increment-on-open semantics). No behaviour
change for the fresh-@ path — existing browser suite is the regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
8a1cc2d1f0 chore(i18n): drop the unused date_original_label key and stale comments
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m39s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
With the visible "Originaltext" line gone from every view, the
date_original_label message has no remaining references — remove it from
de/en/es. Also drop the now-inaccurate comments in documentDate.ts that
described the raw cell as "preserved separately as the visible secondary
line"; the raw cell now only feeds the SEASON word and is never shown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:10:20 +02:00
Marcel
654ac1478c feat(document): surface end-before-start inline on the date form (#678)
Add an endBeforeStart $derived to WhoWhenSection (lexicographic ISO compare,
no Date object) that renders an inline error on the end-date field —
border-red-400, aria-invalid, aria-describedby, and a #end-date-error <p>
inside the existing aria-live region — with a ⚠ glyph so the cue is not
colour-alone (WCAG 1.4.1). Save is not disabled; the server stays the gate.

Wire ErrorCode INVALID_DATE_RANGE through errors.ts getErrorMessage and add
the single key error_invalid_date_range to de/en/es, so the same translated
string is used inline (client) and via getErrorMessage (server fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:27:57 +02:00
Marcel
29ea27319a feat(themen): show the subtree rollup count on reader surfaces (#698)
The /themen page (box header, child rows, aria-labels) and the dashboard
ThemenWidget now display subtreeDocumentCount instead of the direct
documentCount, so a topic's number reflects its whole sub-topic tree and
matches what /documents?tag=X actually returns. A parent with 0 direct
documents but documents under its children now shows a non-zero total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:57:41 +02:00
Marcel
16f1fe7616 feat(themen): key reader tag visibility on the subtree rollup (#698)
Regenerate the TagTreeNodeDTO type with subtreeDocumentCount and switch
hasAnyDocuments to read it directly — the backend rollup already includes all
descendants, so the recursive children walk is no longer needed. Reader
surfaces now hide a topic only when its whole subtree is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:57:41 +02:00
Marcel
397fc3c7e4 test(security): add unit tests for cookies.ts CSRF utilities
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m40s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
CI / Unit & Component Tests (push) Successful in 3m24s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m35s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 20s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
nightly / deploy-staging (push) Successful in 2m10s
Covers getCsrfToken (cookie parsing, URL-decoding, server-side null),
withCsrf (header injection, immutability, no-op when absent),
makeCsrfFetch (method filtering, case-insensitivity, inner-vs-global),
and csrfFetch (regression guard: vi.stubGlobal is honoured at call time,
not bypassed by a module-level captured reference).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:55:55 +02:00
Marcel
5d8d85057d fix(security): make csrfFetch a function to respect vi.stubGlobal mocks
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m34s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
The previous `export const csrfFetch = makeCsrfFetch(fetch)` captured the
global fetch at module evaluation time. Tests that mock fetch via
`vi.stubGlobal('fetch', mockFetch)` set up their stub *after* module import,
so all calls through csrfFetch bypassed the mock — 21 browser tests saw 0
fetch calls.

Changing csrfFetch to a plain function means `fetch` is resolved from the
global scope at each call site, picking up whatever stub is in place at
call time. Production behaviour is identical; test isolation is restored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:37:53 +02:00
Marcel
58254b492b fix(security): add csrfFetch wrapper and apply to all client-side mutating requests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m52s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
Introduces `csrfFetch` (= `makeCsrfFetch(fetch)`) in cookies.ts as a
drop-in fetch replacement that auto-injects X-XSRF-TOKEN on POST/PUT/PATCH/DELETE.

Previously 8 call sites sent mutating requests without the CSRF header —
annotation resize, comment POST/PATCH/DELETE, Geschichte CRUD, Stammbaum
relationship creation, bulk-edit PATCH, and file upload — all would fail
with CSRF_TOKEN_MISSING if the backend's cookie-based protection triggered.

All 14 client-side mutating fetches now use csrfFetch; withCsrf/makeCsrfFetch
remain in the API for injectable-fetch use cases (e.g. useTranscriptionBlocks).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:50:56 +02:00
Marcel
d5a7974f3a fix(shared): trapFocus restores focus to the opener on destroy (#692)
When the bottom sheet closes, focus returns to the element that was focused
before it opened instead of being dropped to document.body (WCAG 2.4.3,
Architect + UX review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:50:54 +02:00
Marcel
ccc37fe1bb feat(shared): add trapFocus action for modal overlays (#692)
Focuses the first focusable on mount and wraps Tab/Shift+Tab within the node.
Used by the Stammbaum mobile bottom sheet (NFR-A11Y-004).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:04:12 +02:00
Marcel
4581fc0b1f test(discussion): atomically clear mention searchbox to kill CI flake
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
userEvent.clear deletes per-keystroke, so intermediate values 'Au'/'A'
transit through the bound searchQuery and each schedules a debounced
fetch. When CI keystroke jitter exceeds SEARCH_DEBOUNCE_MS (150 ms), an
intermediate timer fires before the input reaches '' and the count
assertion sees a phantom q=Au call. fill('') drops a single input event
so the empty-query branch wins deterministically — same pattern this
test file already uses for fill('Walter').

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:53:36 +02:00
Marcel
9d9cd644ec Merge remote-tracking branch 'origin/main' into HEAD
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
# Conflicts:
#	frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts
#	frontend/src/routes/+page.server.ts
2026-05-27 22:16:26 +02:00
Marcel
3a758393bf refactor(shared): extract hasWriteAll(locals) permission helper
The locals.user.groups.some(...WRITE_ALL) derivation was copy-pasted across
the persons directory, persons review and the two document loaders touched by
this PR. Extract a single tested hasWriteAll(locals) helper in
$lib/shared/server and reuse it, removing the ad-hoc casts.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:14:00 +02:00
Marcel
6c3552dc6a refactor(persons): update all callers for the paged /api/persons response
GET /api/persons now returns PersonSearchResult { items, … } instead of a bare
list. Update every caller: the dashboard top-persons path reads .items; the
unused full-list fetches in documents/new and documents/[id]/edit are dropped
(both pages use the self-fetching PersonTypeahead); the raw-fetch consumers
(PersonTypeahead, PersonMultiSelect, PersonMentionEditor) read body.items and
pass review=true so search still spans the whole directory. Specs updated to
the new envelope shape.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:56:00 +02:00
Marcel
8ed5b1e9e3 fix(dates): make DAY precision locale-aware in formatDocumentDate
DAY precision routed through formatDate() which hard-coded de-DE, so an
en/es reader saw the German month name ("24. Dezember 1943"). Route DAY
through Intl.DateTimeFormat(locale, …) like the other branches, keeping
the T12:00:00 UTC-safety convention. Add en/es DAY+MONTH parity cases to
docs/date-label-fixtures.json (TS-only; the Java title formatter stays
German by design) and assert them in the spec.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:19:09 +02:00
Marcel
f2a74a6064 feat(frontend): add precision-aware document date formatter
Adds formatDocumentDate — a pure, branch-per-precision label function that
renders a document date at exactly the precision the data claims (DAY → full
date, MONTH → "Juni 1916", SEASON → localized season word, YEAR → "1916",
APPROX → "ca. 1916", RANGE with collapse/expand/open-ended, UNKNOWN → "Datum
unbekannt"). Delegates to the existing date.ts helpers (shared T12:00:00
convention) and routes every localized word through Paraglide.

A shared docs/date-label-fixtures.json table is asserted by this spec and will
be asserted by the Java title formatter, as the drift guard requested in
review (Markus/Sara). Adds de/en/es precision/season/edit-form i18n keys.

Assumption: SEASON structured label is localized per locale (Decision 4),
with the verbatim raw cell preserved as a separate secondary line by callers.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:43:32 +02:00
Marcel
aa6de48a71 feat(importing): add CanonicalSheetReader + IMPORT_ARTIFACT_INVALID
Header-name based POI reader that replaces the brittle positional
@Value app.import.col.* indices. Fails closed (DomainException
IMPORT_ARTIFACT_INVALID) on a missing required header rather than
NPEing on a null column index. Pipe-split helper for list columns.

Mirrors the new ErrorCode into the frontend type, getErrorMessage,
and de/en/es i18n per the 4-step convention.

--no-verify: husky frontend lint cannot run in a worktree; backend-only.

Refs #669

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:21:18 +02:00
Marcel
c9fb14fd49 test(frontend): add required precision/provisional fields to Document/Person mocks
The Document entity schema now carries the required metaDatePrecision field
and the Person schema the required provisional field (both @Schema(REQUIRED)).
Strictly-typed mock literals in three test files omitted them, which would
break `npm run check` once api.ts is regenerated.

- ReaderRecentDocs.svelte.spec.ts: baseDoc gains metaDatePrecision; sender mock
  gains provisional.
- PersonMentionEditor.svelte.spec.ts: AUGUSTE/ANNA gain provisional.
- MentionDropdown.svelte.test.ts: makePerson factory base gains provisional.

--no-verify: husky frontend lint hook cannot run without node_modules in the
worktree; CI's lint + new type-check stage cover this.

Refs #671

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:34:23 +02:00
Marcel
5dac1d993c fix(themen): correct link color and tag navigation route
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m18s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
- Match "Alle Themen →" link style to other reader dashboard widgets (text-ink-2, font-semibold, no-underline)
- Fix tag card hrefs from /?tag= to /documents?tag= — the home page does not handle tag filtering, /documents does

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:29:53 +02:00
Marcel
264d60c855 feat(themen): cap ThemenWidget at 6 tags — link to /themen for full list
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:06:56 +02:00
Marcel
279b4f1098 feat(themen): ThemenWidget component with compact prop + browser tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
2e0eb40aec test(debounce): fix flaky onExit-cancels-debounce test
All checks were successful
CI / fail2ban Regex (push) Successful in 42s
CI / Unit & Component Tests (pull_request) Successful in 4m5s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m35s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
CI / Unit & Component Tests (push) Successful in 3m46s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m27s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
nightly / deploy-staging (push) Successful in 2m13s
The test raced a real 150 ms setTimeout: fill('Walter') started the
debounce, then focus + keyboard(Escape) had to complete before 150 ms
elapsed. Under CI load the Playwright CDP round-trips exceeded 150 ms,
letting the debounce fire first.

Fix: install vi.useFakeTimers() after the stable-state setup (so
vi.waitFor()'s real-timer polling still works), freeze the Walter
debounce, let Escape trigger onExit/cancel, then advance fake time
with vi.advanceTimersByTimeAsync() — no real-wall-clock race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:40:10 +02:00
Marcel
d9e01ef1ff fix(review): regenerate api.ts and fix spec type
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m23s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m55s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Replace manual edits to api.ts with a proper `npm run generate:api` run —
the generated output is identical for DocumentListItem (createdAt/updatedAt
were already correct), so this just removes the drift risk flagged in review.

Fix ReaderRecentDocs.svelte.spec.ts to use DocumentListItem instead of
Document for all test fixtures, matching the component's actual prop type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:25:46 +02:00
Marcel
2e0f85c360 fix(review): address reviewer concerns from PR #661
All checks were successful
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
CI / Unit & Component Tests (pull_request) Successful in 3m50s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 43s
- Replace brittle createdAt===updatedAt isNew() check with a 7-day
  recency window (created within last 7 days = new)
- Add createdAt/updatedAt to searchItem fixture in page.server.spec.ts
  and assert they are propagated to recentDocs
- Replace null timestamps in DocumentListItem test fixtures with a fixed
  LocalDateTime to satisfy the @Schema(required) contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:08:04 +02:00
Marcel
a1035171c2 fix(reader-dashboard): recentDocs items were always undefined for READ_ALL users
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
The server mapped DocumentSearchResult items as { document: Document }[]
but the API returns flat DocumentListItem[] — so i.document was always
undefined, crashing the reader homepage with a 500.

Fix the type + mapping in +page.server.ts, add createdAt/updatedAt to
DocumentListItem (needed by ReaderRecentDocs for relative-time display),
and update the component to accept DocumentListItem instead of Document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:31:55 +02:00
Marcel
f1a61278f9 refactor(frontend): drop unused message field from ApiError interface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:31:53 +02:00
Marcel
1a7e4ce536 refactor(frontend): add ApiError interface and extractErrorCode helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:31:53 +02:00
Marcel
3fa0f59529 test(frontend): add unit spec for extractErrorCode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:31:53 +02:00
Marcel
36d50222ec docs(transcription): explain why SEARCH_RESULT_LIMIT lives in the shared module
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m22s
CI / OCR Service Tests (push) Successful in 20s
CI / Backend Unit Tests (push) Successful in 3m41s
CI / fail2ban Regex (push) Successful in 41s
nightly / deploy-staging (push) Successful in 1m57s
CI / Semgrep Security Scan (push) Successful in 19s
CI / Compose Bucket Idempotency (push) Successful in 59s
Round-4 polish from Felix (#1): SEARCH_RESULT_LIMIT only has one consumer
today (PersonMentionEditor), so it risked masquerading as shared. Add a
one-line rationale that the symmetry with MAX_QUERY_LENGTH and
SEARCH_DEBOUNCE_MS — keeping all @mention knobs in one file — is the
intentional motivation, not a missed inlining.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:36:36 +02:00
Marcel
d47326d01c a11y(transcription): hide visible @mention empty-state from AT and fold empty-query check
Round-4 polish from Leonie (S-2), Felix (#3), Sara (#4):
- Add aria-hidden="true" to the visible empty-state <p> so VoiceOver does
  not double-announce — the persistent sr-only live region is now the
  sole AT source of truth (NVDA already de-duped, VoiceOver did not).
- Extract `searchQuery.trim() === ''` into an `isQueryEmpty` $derived;
  both the announcer branch and the visible empty-state branch now read
  from the single intent-named alias.
- Cover the singular branch of the persistent live region (1 item ->
  "1 Person gefunden" / "1 person found" / "1 persona encontrada").
  Plural was already covered; this closes the missing-branch gap.
- Extend the existing "no aria-live on visible <p>" test to also assert
  aria-hidden="true" so a regression on the AT-source-of-truth contract
  goes red immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:36:36 +02:00
Marcel
0af43043ba test(transcription): polish @mention test docstrings and tighten clip assert
Round-4 polish from Sara (#11199) and Felix (#11186):
- Replace setTimeout(50) in stale-response race with tick() — matches
  round-3 pattern Sara verified in the sticky-takeover test.
- Add intent comment above the "clear input" wait — it is a negative
  assertion that must not be optimised away.
- Tighten displayName-clip assert from <=100 to ===100 so the test
  discriminates "clip works" from "clip works AND nothing weakened it".
- JSDoc POST_DEBOUNCE_SLACK_MS with the calibration rationale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:36:36 +02:00
Marcel
8f0fb89e22 a11y(transcription): persistent aria-live region for @mention dropdown
The aria-live region previously lived inside {#if items.length === 0} so
it remounted whenever items transitioned between empty and populated —
VoiceOver in particular swallows announcements from freshly-mounted live
regions, and the "N persons found" announcement was missing entirely on
the populated branch. Move the live region above the conditional so the
element persists, and announce a localized "1 person found" / "N persons
found" count on the populated branch. The visible empty-state <p> stays
as a visual cue (no aria-live). Leonie #3 on PR #629 round 3.

Adds person_mention_results_count_singular / _plural in de/en/es.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:36:36 +02:00
Marcel
4ee36b2047 test(transcription): make @mention onKeyDown tests consistent
Wrap all four onKeyDown unit tests (ArrowDown/ArrowUp/Enter/Escape) in
flushSync uniformly so the next reader doesn't have to figure out why
some are wrapped and others aren't. Felix #1 on PR #629 round 3.

Also add a comment above the describe block calling out that these unit
tests do NOT exercise the Tiptap forwarding chain — that is covered by
the 'ArrowDown moves the highlight' integration test. Sara #3 on PR #629
round 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:36:36 +02:00