Commit Graph

158 Commits

Author SHA1 Message Date
Marcel
143622bf27 refactor(fts): address PR #488 review concerns
- Extract isPureTextRelevance() private static method to replace the
  7-clause inline boolean in searchDocuments
- Guard long→int cast in relevanceSortedPageFromSql to prevent silent
  overflow at page ≥43M (CWE-190)
- resolvePersonName now uses the typed API client (createApiClient)
  instead of raw fetch, aligning with project conventions
- Update DocumentServiceTest stubs to match new FTS path (findFtsPageRaw
  + findAllById instead of findAllMatchingIdsByFts)
- Rewrite page.server.spec.ts person-name tests to mock via path-based
  API dispatch, matching the new api.GET call site

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:35:01 +02:00
Marcel
a239c16c31 fix(documents): sync filter display state with URL on navigation
Three root causes prevented filters from reflecting the URL after SvelteKit
client-side navigation:

1. +page.server.ts now resolves sender/receiver display names in parallel with
   the document search (UUID validation + silent 404 drop), so initialSenderName
   / initialReceiverName land in server data ready for the UI to use.

2. +page.svelte passes initialSenderName, initialReceiverName, and navKey
   (incremented via untrack on every navigation) down to SearchFilterBar.
   The untrack() prevents the effect from re-running due to its own navKey write.

3. SearchFilterBar forwards navKey as resetKey to each PersonTypeahead, which
   already had a void resetKey guard added in the previous commit.

Together these ensure that after navigating to /documents?senderId=<uuid> the
typeahead shows the person's display name, and clicking × reset clears it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
77d282bbeb refactor(documents): split triggerSearch by zoom semantics (#385)
triggerSearch(zoomOverride?) made the call site read "depends on
whether the source event happened to include zoomFrom/zoomTo". Splits
into triggerSearchKeepZoom() and triggerSearchWithZoom(from, to) so
the contract is explicit at every call site. Closes Felix's review
nit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:02:02 +02:00
Marcel
52827ccc87 feat(documents): hide timeline density widget below lg (#385)
Tablet (640–1024px) is exactly the iPad audience for transcribers.
At 240 monthly bars on an 800px column the bars fall to ~3.3px wide,
well below the 44×44 touch-target floor. Bumps the visibility class
from hidden sm:block to hidden lg:block and matches the page.ts
matchMedia gate to (min-width: 1024px). Closes Leonie's tablet
touch-target finding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:00:47 +02:00
Marcel
5d92f5a32b refactor(documents): rework timeline UX after live testing (#385)
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m29s
CI / OCR Service Tests (push) Successful in 48s
CI / Backend Unit Tests (push) Failing after 3m46s
CI / Unit & Component Tests (pull_request) Failing after 4m31s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
Replaces the discrete zoom-in button with a Graylog-style drag-to-zoom
range selector and adds X/Y axis labels so the chart is readable.

Drag interaction
- Pointerdown on a bar attaches document-level pointermove/pointerup/
  pointercancel listeners; pointermove maps clientX to a bar index via
  the row's bounding rect, so the mint-bordered window expands smoothly
  even when the cursor leaves the bar or the chart entirely.
- pointerup commits filter + zoom atomically. Same-bar release on a
  year bar (year-aggregated mode) zooms into that year's months;
  same-bar release on a month bar emits filter-only.
- setPointerCapture removed — it was suppressing pointerenter on
  sibling bars and preventing the drag window from expanding.
- Bar buttons are now h-full so the entire 80 px column is the hit
  target, not just the visible bar height.

Axis labels
- Y-axis: max-count and 0 labels left of the bar area.
- X-axis: tickIndicesFor() picks decadal years for long ranges, evenly
  spaced months for short year-zoom views, January boundaries for
  multi-year month ranges. formatTickLabel() drops the year when the
  visible range is a single year so 12-month zooms read "Jan Feb Mär…".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:54:48 +02:00
Marcel
a6123e1867 feat(documents): zoom-in tool for the timeline (#385)
Adds a zoom action that narrows the visible timeline range to the current
selection so the user can drill from year-level back into month-level
density. Zoom state lives in the URL (zoomFrom / zoomTo) so it survives
reload and is shareable.

- New `clipBucketsToRange(buckets, from, to)` helper applied before the
  >240-month year-aggregate decision, so a zoomed window flips back to
  month bars automatically when the clip narrows the range enough.
- `TimelineDensityFilter` gains `zoomFrom`, `zoomTo`, and `onzoomchange`
  props. Zoom button shown only when a selection exists and we aren't
  already zoomed; reset-zoom shown only when zoomed. Both placed in a
  shared right-edge action cluster alongside the × clear button.
- `+page.ts` reads zoomFrom/zoomTo from the URL and forwards them as
  props. `+page.svelte` extends FilterSnapshot + buildSearchParams, and
  triggerSearch accepts an optional zoom override so the onzoomchange
  callback can write the new pair (or clear them) atomically.
- 7 new component tests + 2 new page-integration tests cover the
  visibility rules and URL writes.
- 4 new unit tests for `clipBucketsToRange`.
- 3 new i18n keys (zoom in / zoom reset / drag aria-live) across de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:23:38 +02:00
Marcel
76023a99ed feat(documents): timeline density refetches when other filters change (#385)
The +page.ts client-side load now forwards the active /documents URL
filters (q, senderId, receiverId, tag, tagQ, status, tagOp) to
/api/documents/density so the bars recompute when the user narrows the
search. Date bounds (from/to) are deliberately omitted — the chart is
the surface for picking those.

- New `DensityFilters` type and `buildDensityUrl(filters)` helper.
- `fetchDensity` accepts a filter snapshot (defaulting to {} for
  back-compat in tests).
- 6 new unit tests cover URL building, multi-tag repetition, AND/OR
  forwarding, the explicit-no-from/to invariant, and filter-aware fetch.
- Generated API types refreshed against the new backend signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:10:12 +02:00
Marcel
8e29f428d7 fix(documents): merge +page.server data into +page.ts return (#385)
SvelteKit's PageData type generation only picks up +page.ts return values
when both files exist, so the runtime-merged server data was invisible to
TypeScript and svelte-check flagged every q/from/to/etc access in
+page.svelte. Spreading data into the +page.ts return restores the merge
at the type level. No runtime behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:29:24 +02:00
Marcel
6786c0112d feat(documents): wire TimelineDensityFilter into /documents/+page (#385)
Mounts the timeline above the result count, hidden on mobile via
\`hidden sm:block\` (defense-in-depth — +page.ts already gates the fetch).
The component's onchange callback updates local from/to and triggers
the existing search reload, so timeline selection composes with the
SearchFilterBar's other filters via AND semantics for free.

3 new page-level integration tests cover: widget renders when density
present, hides when null, and bar click navigates with correct
from/to URL params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:16:05 +02:00
Marcel
ad82f2e1e2 feat(documents): add fetchDensity helper and /documents/+page.ts (#385)
The density data is fetched only on tablet/desktop (sm:+ breakpoint) and
when ?view=calendar is not set — mobile users and the future calendar view
(#386) skip the request entirely. Lives in +page.ts (client-side) so the
matchMedia gate can run in the browser; +page.server.ts continues to handle
the document search.

Non-ok responses and network failures degrade to an empty bucket list
rather than throwing, so the document list keeps rendering.

5 unit tests cover the gating + graceful degradation paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:06:57 +02:00
Marcel
c10e8e8a3a fix(tests): replace flaky waitFor with synchronous dispatchEvent in edit-page delete spec
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m54s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 3m51s
CI / OCR Service Tests (push) Successful in 47s
CI / Backend Unit Tests (push) Failing after 3m19s
The Playwright CDP click latency occasionally pushed past vi.waitFor's 1000ms
deadline, making the "opens a confirm dialog" test flaky. Switched to
btn.dispatchEvent(new MouseEvent(...)) — the same synchronous in-browser pattern
already used in GeschichteEditor.svelte.spec.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:37:13 +02:00
Marcel
6ab7abb9df fix(tests): fix 3 pre-existing vitest-browser spec failures
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m41s
CI / OCR Service Tests (push) Successful in 43s
CI / Backend Unit Tests (push) Failing after 3m30s
CI / Unit & Component Tests (pull_request) Failing after 3m32s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
Three distinct root causes:

1. hilfe/transkription: Wikipedia link test was checking .textContent but
   the accessible text had moved to aria-label in a prior commit.

2. documents/[id]/edit: vi.spyOn on a Svelte 5 compiled .svelte.ts service
   object does not reliably track calls in vitest-browser mode; replaced
   with a plain closure-based mock.

3. GeschichteEditor: TipTap's onMount steals focus and its ProseMirror
   view interferes with Playwright CDP event dispatch. Three workarounds:
   - blur: dispatchEvent(new FocusEvent('blur')) bypasses focus-state check
   - save buttons: dispatchEvent(new MouseEvent('click')) from in-browser JS
     context reliably triggers Svelte 5 onclick vs. Playwright CDP click
   - trailing-space fill: input.value + dispatchEvent('input') works where
     userEvent.fill('value ') silently fails to update bind:value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:27:24 +02:00
Marcel
567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00
Marcel
efcc347c00 refactor: move shared components to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:40:14 +02:00
Marcel
d6db7a07bd refactor: move shared utilities to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:35:15 +02:00
Marcel
d5d36e661a refactor: move person domain components and utils to lib/person/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:26:21 +02:00
Marcel
1e656d2db4 refactor: move document transcription, annotation, viewer sub-packages
- transcription/: TranscriptionBlock, Column, EditView, PanelHeader, ReadView,
  Section + transcriptionMarkers, blockConflictMerge, saveBlockWithConflictRetry
  + useBlockAutoSave, useBlockDragDrop hooks
- annotation/: AnnotationLayer, AnnotationShape, AnnotationEditOverlay
- viewer/: PdfViewer, PdfControls + useFileLoader, usePdfRenderer hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:01:39 +02:00
Marcel
e7f8aa5894 refactor: move document domain core to lib/document/
Moves ~25 components, utils (search, filename, groupDocuments,
documentStatusLabel, validateFile), bulkSelection store, and
TranscriptionSection sub-component. Fixes broken relative imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:56:36 +02:00
Marcel
ed270f68e1 feat(geschichten): wire discovery integrations on Person and Document pages
Person detail (/persons/[id]):
- Server load fetches GET /api/geschichten?status=PUBLISHED&personId={id}
  in parallel with the existing person/document queries.
- Renders <GeschichtenCard> below the received-documents list when the
  person has at least one published story.

Document detail (/documents/[id]):
- Server load adds the same parallel call with documentId={id}.
- DocumentTopBar gains geschichten + canBlogWrite props that flow through
  to DocumentMetadataDrawer.
- DocumentMetadataDrawer's grid expands to lg:grid-cols-4 when the
  Geschichten column should appear (stories exist OR user can author),
  and shows "+ Geschichte anhängen" / "Alle anzeigen" links following the
  >= 3-story threshold from issue comment #5758.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:01:19 +02:00
Marcel
9e7861fa03 feat(geschichten): frontend foundation — canBlogWrite, sanitize util, nav, i18n
- Derives canBlogWrite in +layout.server.ts the same way as canAnnotate.
- Adds Geschichten link to AppNav (desktop + mobile, between Stammbaum and Admin).
- Adds error_geschichte_not_found mapping to errors.ts and translation keys
  for the Geschichten index, detail, editor, and confirmation copy in
  de/en/es.
- Adds isomorphic-dompurify-backed safeHtml() helper with allow-list
  matching the backend OWASP policy (p/br/strong/em/h2/h3/ul/ol/li),
  plus Vitest spec.
- Updates legacy spec test data so the new required canBlogWrite layout
  prop type-checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:43:29 +02:00
Marcel
db66d0cc61 fix(document-page): add .catch() to task deep-link tick promise
Some checks failed
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m7s
CI / Unit & Component Tests (push) Failing after 3m22s
Addresses @felix — tick().then() had no error handler; console.error
is now logged on failure, matching the existing deep-link scroll pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 21:38:05 +02:00
Marcel
7dc5dc6f71 feat(document-page): auto-open transcription panel when ?task=transcribe is present
On mount, reads the task query param before the comment deep-link handler.
When task=transcribe, opens the transcription panel, scrolls the close button
into view, moves focus to it, then strips the param from the URL via replaceState.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 21:38:05 +02:00
Marcel
ba73387d50 refactor(transcription): extract saveBlockWithConflictRetry into a util
Tester #5506 §2 + Markus #5504 §2: the 409 orchestration was inline in
+page.svelte and untested. Extract into a pure module that takes the
fetch function as a dependency, so the full happy path / 409 path / 500
path / refetch-fails path / UUID-guard path can be unit-tested with
mock Responses. The route file now reads as 12 lines: call the helper,
on conflict apply the merged snapshot to local state, re-throw.

BlockConflictResolvedError now carries the merged block on its
`merged` property so callers don't have to redo the refetch.

6 new unit tests cover every branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:20:49 +02:00
Marcel
43aacd9f60 fix(transcription): UUID-guard saveBlock path interpolation
Sina #5505 concern 1: doc.id and blockId are server-trusted today, but
the path-interpolation pattern is repeated three times across the route
and the autosave hook. Validate both ids against the standard UUID
regex before any fetch fires so a future feature taking user-supplied
ids cannot silently introduce a path-injection vector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:09:52 +02:00
Marcel
fd3a44d10c refactor(transcription): typed BlockConflictResolvedError instead of prose throw
Felix #3: the 409 path was throwing a human-prose Error which read like
an i18n string that escaped translation. Replace with a named class
carrying code='CONFLICT_RESOLVED' so callers can branch on intent and
future error reporters can map the structured code instead of grepping
strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:05:47 +02:00
Marcel
64a61f705c feat(transcription): handle 409 rename-mid-edit conflict on block save (B12b)
When PersonService renames a person while a transcriber is editing a
block that mentions them, the block-save endpoint returns 409 (carrying
the new ErrorCode.PERSON_RENAME_CONFLICT from PR-A). saveBlock now:

1. Refetches the latest server snapshot of the block.
2. Calls mergeBlockOnConflict to combine: server's mentionedPersons
   (post-rename displayNames win) + transcriber's unsaved text + any
   local-only mentions added since the last save.
3. Updates the local block state with the merged result.
4. Re-throws so the autosave indicator surfaces the conflict and the
   pending payload is preserved for retry (B12).

The merge logic is a pure function so it can be unit-tested in
isolation and reused for any future conflict-resolution scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:35:27 +02:00
Marcel
02d3e2ab61 feat(transcription): swap plain textarea for PersonMentionEditor and thread mentionedPersons through autosave
- TranscriptionBlockData now carries mentionedPersons (matches backend
  schema added in PR-A).
- useBlockAutoSave.saveFn signature widens to (blockId, text, mentions);
  pendingMentions is tracked alongside pendingTexts and is preserved on
  failure so a retry resends the in-flight payload (B12).
- TranscriptionBlock.svelte renders <PersonMentionEditor>, exposing the
  textarea node back through a captureTextarea callback so the existing
  quote-selection feature still works.
- saveBlock in routes/documents/[id]/+page.svelte forwards mentions on
  PUT.
- flushOnUnload sends mentions in the keepalive payload too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:32:09 +02:00
Marcel
b658a13247 feat(stammbaum): show inferred relationship in the document drawer
- New presentational RelationshipBadge component (labelFromA → arrow →
  labelFromB) wired into DocumentMetadataDrawer's Personen column,
  rendered after the receivers block when both endpoints are family
  members.
- DocumentTopBar gains an optional inferredRelationship prop and
  passes it through.
- documents/[id]/+page.server.ts loads the badge: only when sender is
  a family member, exactly one receiver, and that receiver is also a
  family member; 404 (no path) → null.
- relationshipLabels.ts maps the backend label keys (parent/child/...)
  to localised strings, so the server load returns badge-ready strings.

Refs #358.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 19:32:17 +02:00
f6cf2e0e42 feat(transcription): add "Alle als fertig markieren" bulk action (#345) (#352)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 51s
CI / Backend Unit Tests (push) Failing after 3m13s
## Summary

Implements the bulk "Alle als fertig markieren" action for the transcription panel requested in #345.

### Backend

- Added `PUT /api/documents/{documentId}/transcription-blocks/review-all` endpoint to `TranscriptionBlockController`, guarded with `@RequirePermission(Permission.WRITE_ALL)`
- Added `markAllBlocksReviewed(UUID documentId, UUID userId)` to `TranscriptionService` — `@Transactional`, single DB round-trip via `blockRepository.saveAll()`, emits one `BLOCK_REVIEWED` audit event per previously-unreviewed block
- Returns full updated block list (same shape as `listBlocks`) for a clean frontend update pass
- 5 new `TranscriptionServiceTest` unit tests (idempotency, audit events, empty document)
- 5 new `TranscriptionBlockControllerTest` `@WebMvcTest` tests (401, 403, 200 happy path, 200 empty, 401 user not found)
- All 68 backend tests pass

### Frontend

- Added `onMarkAllReviewed?: () => Promise<void>` prop to `TranscriptionEditView` (optional, consistent with `onTriggerOcr` pattern)
- Button placed in sticky progress header, right-aligned next to `reviewedCount / totalCount geprüft`
- Button is **disabled** (not hidden) when all blocks are already reviewed — `title="Alle Blöcke sind bereits als fertig markiert"` (Decision 1)
- Loading spinner replaces checkmark icon during operation — always shown (Decision 4, no threshold)
- Handler `markAllReviewed()` added to `documents/[id]/+page.svelte`, wired as `onMarkAllReviewed`
- 5 new `TranscriptionEditView.svelte.spec.ts` Vitest Browser component tests; all 25 tests pass

### Decisions applied

| # | Question | Choice |
|---|---|---|
| 1 | Button when all reviewed | **Disabled** with `title` tooltip |
| 2 | Audit log | **N individual BLOCK_REVIEWED events** (one per unreviewed block) |
| 3 | Atomicity | **All-or-nothing** via `@Transactional` |
| 4 | Loading indicator | **Always show** during operation |

Closes #345

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/352
2026-04-28 08:34:26 +02:00
Marcel
163e99016a fix(viewer): check res.ok on orphaned annotation DELETE to surface errors
Without the guard, a failed DELETE (4xx/5xx) was silently swallowed and
annotationReloadKey was incremented anyway, leaving the annotation visible
and the user with no feedback. Now matches the deleteBlock() pattern
immediately above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
d6f3ca5c43 feat(viewer): show delete icon on annotation for direct block deletion (#339)
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
18cad798fc fix(documents): preserve archiveBox + archiveFolder in markForReview; drop documentLocation
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m1s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m53s
2026-04-25 20:25:08 +02:00
Marcel
a91ee1f26d refactor(documents): unify count + action links into one row
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 2m56s
Move result count, bulk-edit button, and new-document link into a shared
flex row so they appear on the same line. Adds an edit icon to the
bulk-edit button to visually match the existing plus icon on the add link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:41:24 +02:00
Marcel
1803db86b5 test(bulk-edit): plug Sara's identified coverage gaps
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m54s
- DocumentServiceTest.applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable (Sara C1)
- DocumentServiceTest.findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec (Sara C3)
- bulkSelection.svelte.spec.ts: setAll([]) no-op + previous-IDs-absent + ids getter (Sara C4 + S4)
- /documents/bulk-edit/+page.server.ts now defensively handles a UserGroup
  with NULL `permissions` (treats it as not-WRITE_ALL instead of throwing
  on .includes()) + matching test (Sara C7)

233 backend tests + frontend bulk-edit specs all green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:58:48 +02:00
Marcel
7df00859c6 fix(bulk-edit): pluralization, edit-mode CTA, error UI, real loading state
Elicit C1+C3 — bulk-selection count uses ICU-style plural keys
(bulk_edit_n_selected_one / _other) so n=1 reads as "1 Dokument" instead
of "1 Dokumente". Save CTA in edit mode reads "Anwenden" via the existing
bulk_edit_save_button key; UploadSaveBar grew an editMode prop. Multi-
chunk progress text is now visible (not aria-only).

Felix C2 — bulk-edit page wires the backend error code through
parseBackendError + getErrorMessage instead of falling back to a generic
internal_error.

Felix C5 — editAllMatching no longer swallows fetch failures: the button
shows an inline error with the backend-mapped message (e.g. when the
filter cap is exceeded).

Leonie C8 — replace the literal "…" loading glyph on /documents/bulk-edit
with a spinner + role=status + aria-live=polite + visible "Loading
documents…" text.

Leonie C9 — partial-failure card and bulk-edit page error card now use
the design-system `text-danger` / `bg-danger/10` / `border-danger/40`
tokens (dark-mode safe) instead of raw red palette values.

Leonie C10 + C13 — German plural fixed; EN badges retensed
("+ added" → "+ will be added", "replaced" → "will replace") to match
the future-tense intent of DE/ES.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:46:58 +02:00
Marcel
156efe8b31 fix(bulk-edit): a11y + i18n hardening (Leonie blockers 1–4 + quick concerns)
B1 — i18n the archive-box / archive-folder labels and add helper text.
Karton/Mappe were hardcoded German and broke EN/ES locales (WCAG 3.1.2).

B2 — drop the hardcoded German aria-label on the onboarding callout.
role="note" + the visible localised text is self-describing; the redundant
label was overriding the translated content for AT users on EN/ES.

B3 — Escape clears the bulk selection while the bar is visible. Adds an
"Esc: Auswahl aufheben" hint visible at ≥ sm (WCAG 2.1.1).

B4 — /documents and /enrich reserve pb-32 when the bulk-selection bar is
visible so it doesn't occlude the last row or pagination (WCAG 1.4.10).

Folded in three Leonie quick-concerns:
 - C5: badge text-[10px] → text-[11px], raw text-gray-600 →
        design-token text-ink-2 (dark-mode safe)
 - C7: aria-live="polite" on bulk-selection-count
 - C11: "Alles aufheben" → "Auswahl aufheben" (DE/EN/ES) — disambiguates
        from "discard the operation entirely"

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:35:40 +02:00
Marcel
6d3489d035 feat(bulk-edit): add /documents/bulk-edit route
Server load redirects READ_ALL-only users (or unauthenticated) to /documents.
Page load: onMount reads bulkSelectionStore — redirects to /documents when the
store is empty, otherwise POSTs the IDs to /api/documents/batch-metadata and
hands the resulting summaries to BulkDocumentEditLayout in mode="edit".

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:18:07 +02:00
Marcel
d4f32ed5d4 feat(bulk-edit): add BulkSelectionBar and Alle-X-editieren fast path
- BulkSelectionBar component: sticky bottom bar shown only when canWrite
  and selection is non-empty. Buttons meet WCAG 44px touch targets and
  iOS safe-area inset is honoured.
- Bar mounted on /documents and /enrich.
- Alle X editieren button on /documents replaces the selection with
  every UUID matching the active filter (via /api/documents/ids) and
  jumps to /documents/bulk-edit.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:07:26 +02:00
Marcel
539842e849 fix(bulk-upload): spec-compliant split-panel layout with local PDF preview
Rewrites BulkDocumentEditLayout to match the spec exactly:
- Fixed viewport layout (same as DocumentEditLayout) filling viewport below nav
- Split panel visible in all states (N=0/1/≥2) — was fullscreen dark drop zone
- N=0: centered drop-zone-box in left panel; shared form visible but greyed out
- N≥1: real PDF preview via URL.createObjectURL (no server upload required)
- N≥2: FileSwitcherStrip at bottom of left panel; count pill + discard in topbar
- FileEntry gains previewUrl; blob URLs created on add, revoked on remove/destroy
- save() checks response.ok and marks failed files with status: 'error'
- BulkDropZone redesigned: spec-accurate box with circular mint icon, serif title
- FileSwitcherStrip: number badges, arrows, keyboard nav via data-chip-id selector
- ScopeCard, UploadSaveBar: hardcoded German replaced with Paraglide i18n keys
- +page.svelte simplified to bare component render (layout is self-contained)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ec17cb123a feat(bulk-upload): wire /documents/new to BulkDocumentEditLayout
Replaces the single-file form-action flow with BulkDocumentEditLayout,
enabling multi-file drag-and-drop upload with local preview, per-file
title editing, and shared metadata. Server load function unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
8fa061187e refactor(documents): extract buildSearchParams — address @felixbrandt
triggerSearch (local state, filter change) and buildPageHref (server data,
page nav) were each iterating over the same ~10 filter params. Any new
filter would have had to land in two places. buildSearchParams is now the
single source of truth for which params the /documents URL understands;
both callers just pass their snapshot and an optional targetPage. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
78ac5d663d feat(documents): paginate search with a Pagination control
Frontend side of the /documents pagination work. The page.server.ts load
reads ?page= from the URL, forwards page+size=50 to the backend, and
exposes the new totalElements/pageNumber/pageSize/totalPages fields on
`data`. +page.svelte renders a <Pagination> component below the result
list; buildPageHref preserves every filter param and only updates page.
The existing triggerSearch debounce flow intentionally drops `page`
when any filter changes, so filter edits reset to page 0 automatically.

<Pagination> uses plain <a href> links (not goto) so SvelteKit's default
scroll restoration scrolls new pages to the top — the expected senior-UX
behaviour. Decorative chevrons wrapped in aria-hidden spans, 44px touch
targets, focus-visible ring, stacks vertically under 640px. The control
hides itself when totalPages ≤ 1.

Test coverage: 9 cases on Pagination (label, aria-current, prev/next
enable/disable, makeHref invocation, decorative chevron, touch target),
plus a filter-reset assertion on +page.svelte (page 5 → edit q →
goto URL must drop page=). Adds i18n keys in de/en/es. Manual edit to
api.ts pending a post-merge npm run generate:api against a rebuilt
dev backend. (#315)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
367dcc66f2 refactor(nav): add class prop to BackButton, remove mb-4 from topbar contexts
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m44s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m51s
- BackButton now accepts a `class` prop (default 'mb-4') so callers can
  override spacing; resolves hardcoded margin in flex-row topbar snippets
- documents/[id]/edit and enrich/[id] pass class="" to suppress the margin
- Replace weak className unit test with class-prop behaviour tests
- Add [data-hydrated] comment in E2E spec explaining what emits the attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:16:49 +02:00
Marcel
cc9c47254b refactor(nav): replace static back-link hrefs with BackButton
All 7 in-scope back navigation links converted to use history.back().
Admin panel mobile chevron converted inline (icon-only, different
visual pattern). Cancel buttons left as static <a> links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:49:23 +02:00
Marcel
b07f9efa9c fix(document-detail): force edit panel on notification deep-link
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m39s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m46s
Comments only render inside TranscriptionEditView, so a deep-link
into a document with existing reviewed transcriptions landed the
user in read mode with no comment element in the DOM — the scroll
target silently missed.

scrollToCommentFromQuery now takes a setPanelMode callback and calls
it with 'edit' whenever both query params are present. The page's
own transcribe-mode $effect checks a skipInitialPanelMode flag the
deep-link flow sets, so its default-panel-mode logic doesn't race
against the explicit override.

Two new helper tests pin the contract: panel mode is forced to
'edit' both when transcribe mode is off (entering fresh) and when
it is already on (same-page notification click).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:22:38 +02:00
Marcel
3bf0b38c42 polish(document-detail): address review concerns on onMount deep-link wiring
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m40s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
CI / Unit & Component Tests (push) Failing after 2m41s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m55s
Three small refinements from Felix's review cycle 1:

- replaceState(page.url.pathname, page.state ?? {}) — defend against
  first-navigation cases where page.state can be undefined.
- Extract the inline tick + requestAnimationFrame into a named
  waitForPanelRender() helper; intent is now readable from onMount.
- Attach .catch() to the fire-and-forget scrollToCommentFromQuery
  promise so any helper throw surfaces via console.error instead
  of silently disappearing.

No behavior change on the happy path. All existing tests stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:10:46 +02:00
Marcel
e22265f5bc feat(document-detail): wire notification deep-link scroll in onMount
After navHeight setup, call scrollToCommentFromQuery with the page
URL and callbacks into the component's local state (transcribeMode,
activeAnnotationId, flashAnnotationId) plus SvelteKit's replaceState
to strip the consumed query params.

afterTick awaits both Svelte's tick() and one requestAnimationFrame,
mirroring the existing handleAnnotationClick timing so the annotation
panel has rendered before scrollIntoView fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:42:55 +02:00
Marcel
33ada55f12 feat(documents): restore sender/receiver grouping in document list
When sort=SENDER, documents group under the sender's display name card.
When sort=RECEIVER, a document appears under each receiver's card
(with multi-receiver duplication). Falls back to i18n labels for unknown
sender/receiver. Passes sort prop from /documents page to DocumentList.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:29:33 +02:00
Marcel
da2ece986a fix(documents): WCAG text-size and API error pattern fixes
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m37s
CI / OCR Service Tests (pull_request) Successful in 29s
CI / Unit & Component Tests (push) Failing after 2m44s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m45s
CI / Backend Unit Tests (pull_request) Failing after 2m45s
- DashboardResumeStrip: text-[10px] → text-xs on collaborator initials (WCAG 1.4.4)
- documents/+page.server.ts: use !result.response.ok per CLAUDE.md; keep narrow try/catch for network-level failures only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 01:17:04 +02:00
Marcel
7f23e88b69 fix(documents): address review cycle 2 — a11y, CSS injection, debounce tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Unit & Component Tests (push) Failing after 2m40s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m54s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
- ContributorStack: text-xs for WCAG 1.4.4 (was text-[10px]), safeColor()
  validation to block CSS injection via actor.color, role="img" aria-label
  on empty placeholder, {#each} keyed by index
- ContributorStack spec: update empty-state assertion to getByRole('img')
- DocumentRow spec: add stopPropagation regression test for tag click
- documents/page.svelte.spec.ts: new — debounce, URL building, initial state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:40:48 +02:00