From beca2d463af1ce8dde2d7002250c1918fc3cf691 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:07:39 +0200 Subject: [PATCH 01/26] feat(search): extend hasText to match sender/receiver/tag names, add hasTagPartial - hasText now JOINs sender (LEFT JOIN) and uses EXISTS subqueries for receivers and tags to avoid duplicate rows - hasTagPartial added for live debounced tag text filter (ILIKE partial match) Co-Authored-By: Claude Sonnet 4.6 --- .../repository/DocumentSpecifications.java | 71 ++++++++++++++++--- .../DocumentSpecificationsTest.java | 56 +++++++++++++++ 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index cf50675b..b936f16c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -8,24 +8,58 @@ import java.util.UUID; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; public class DocumentSpecifications { - // Filtert nach Text (in Titel, Dateiname oder Transkription) + // Filtert nach Text (in Titel, Dateiname, Transkription, Ort, Absender- und Empfängername, Tags) public static Specification hasText(String text) { return (root, query, cb) -> { if (!StringUtils.hasText(text)) return null; String likePattern = "%" + text.toLowerCase() + "%"; + // LEFT JOIN on sender (ManyToOne — no duplicate rows) + Join senderJoin = root.join("sender", JoinType.LEFT); + + // EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs + Subquery receiverSub = query.subquery(Long.class); + Root receiverRoot = receiverSub.from(Document.class); + Join receiverJoin = receiverRoot.join("receivers"); + receiverSub.select(cb.literal(1L)) + .where( + cb.equal(receiverRoot.get("id"), root.get("id")), + cb.or( + cb.like(cb.lower(receiverJoin.get("lastName")), likePattern), + cb.like(cb.lower(receiverJoin.get("firstName")), likePattern) + ) + ); + + // EXISTS subquery for tag name — avoids duplicate rows for multi-tag docs + Subquery tagSub = query.subquery(Long.class); + Root tagRoot = tagSub.from(Document.class); + Join tagJoin = tagRoot.join("tags"); + tagSub.select(cb.literal(1L)) + .where( + cb.equal(tagRoot.get("id"), root.get("id")), + cb.like(cb.lower(tagJoin.get("name")), likePattern) + ); + + query.distinct(true); + return cb.or( cb.like(cb.lower(root.get("title")), likePattern), cb.like(cb.lower(root.get("originalFilename")), likePattern), cb.like(cb.lower(root.get("transcription")), likePattern), - cb.like(cb.lower(root.get("location")), likePattern)); + cb.like(cb.lower(root.get("location")), likePattern), + cb.like(cb.lower(senderJoin.get("lastName")), likePattern), + cb.like(cb.lower(senderJoin.get("firstName")), likePattern), + cb.exists(receiverSub), + cb.exists(tagSub) + ); }; } @@ -55,13 +89,13 @@ public class DocumentSpecifications { return cb.lessThanOrEqualTo(root.get("documentDate"), end); }; } - + // Filtert nach Status public static Specification hasStatus(DocumentStatus status) { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } - // Filtert nach Schlagworten (UND-Verknüpfung) + // Filtert nach Schlagworten (UND-Verknüpfung, exakter Match) public static Specification hasTags(List tags) { return (root, query, cb) -> { if (tags == null || tags.isEmpty()) @@ -72,15 +106,13 @@ public class DocumentSpecifications { for (String tagName : tags) { if (!StringUtils.hasText(tagName)) continue; - // Subquery erstellen: "Gibt es für dieses Dokument (root.id) einen Tag mit dem Namen X?" - // Dies stellt sicher, dass ALLE Tags vorhanden sein müssen (AND Logik). Subquery subquery = query.subquery(Long.class); Root subRoot = subquery.from(Document.class); Join subTags = subRoot.join("tags"); subquery.select(subRoot.get("id")) .where( - cb.equal(subRoot.get("id"), root.get("id")), // Korrelation zum Haupt-Query + cb.equal(subRoot.get("id"), root.get("id")), cb.equal(cb.lower(subTags.get("name")), tagName.trim().toLowerCase()) ); @@ -90,5 +122,26 @@ public class DocumentSpecifications { return cb.and(predicates.toArray(new Predicate[0])); }; } - -} \ No newline at end of file + + // Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche + public static Specification hasTagPartial(String tagQ) { + return (root, query, cb) -> { + if (!StringUtils.hasText(tagQ)) + return null; + String likePattern = "%" + tagQ.toLowerCase() + "%"; + + Subquery subquery = query.subquery(Long.class); + Root subRoot = subquery.from(Document.class); + Join tagJoin = subRoot.join("tags"); + + subquery.select(cb.literal(1L)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.like(cb.lower(tagJoin.get("name")), likePattern) + ); + + return cb.exists(subquery); + }; + } + +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java index 447f26e3..c2691213 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -250,6 +250,62 @@ class DocumentSpecificationsTest { assertThat(result).isEmpty(); } + @Test + void hasText_findsByPartialSenderLastName() { + List result = documentRepository.findAll(Specification.where(hasText("üller"))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief"); + } + + @Test + void hasText_findsByPartialReceiverLastName() { + List result = documentRepository.findAll(Specification.where(hasText("schmid"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasText_findsByPartialTagName() { + List result = documentRepository.findAll(Specification.where(hasText("amili"))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Familienfoto"); + } + + @Test + void hasText_doesNotProduceDuplicatesForDocumentWithMultipleReceivers() { + Person receiver2 = personRepository.save(Person.builder().firstName("Karl").lastName("Schmidt").build()); + briefEarly.setReceivers(new java.util.HashSet<>(Set.of(receiver, receiver2))); + documentRepository.save(briefEarly); + + List result = documentRepository.findAll(Specification.where(hasText("schmid"))); + assertThat(result).hasSize(1); + } + + // ─── hasTagPartial ──────────────────────────────────────────────────────── + + @Test + void hasTagPartial_returnsAllDocuments_whenTextIsNull() { + List result = documentRepository.findAll(Specification.where(hasTagPartial(null))); + assertThat(result).hasSize(3); + } + + @Test + void hasTagPartial_findsByPartialTagName() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("amili"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasTagPartial_isCaseInsensitive() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("URLAUB"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief"); + } + + @Test + void hasTagPartial_returnsEmpty_whenNoTagMatches() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("xyz"))); + assertThat(result).isEmpty(); + } + // ─── hasStatus ──────────────────────────────────────────────────────────── @Test -- 2.49.1 From c2b5008c660cd9836bdeba3611a42a9351f54d76 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:13:06 +0200 Subject: [PATCH 02/26] feat(search): add sort param (DATE/TITLE/SENDER/RECEIVER/UPLOAD_DATE) and tagQ filter - DocumentSort enum validated by Spring MVC (400 for unknown values) - SENDER sort uses Spring Data Sort on sender.lastName/firstName - RECEIVER sort uses in-memory sort by first receiver alphabetically - UPLOAD_DATE sort uses createdAt; default sort is DATE DESC - tagQ param wired to hasTagPartial specification Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 8 +++- .../familienarchiv/model/DocumentSort.java | 5 ++ .../service/DocumentService.java | 46 +++++++++++++++++-- .../controller/DocumentControllerTest.java | 13 ++++-- .../service/DocumentServiceTest.java | 4 +- 5 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index f661e8aa..57a0f437 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.security.Permission; @@ -193,8 +194,11 @@ public class DocumentController { @RequestParam(required = false) UUID senderId, @RequestParam(required = false) UUID receiverId, @RequestParam(required = false, name = "tag") List tags, - @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) { - return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status)); + @RequestParam(required = false) String tagQ, + @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status, + @Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort, + @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) { + return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir)); } // --- VERSIONS --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java new file mode 100644 index 00000000..6456ef54 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.model; + +public enum DocumentSort { + DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index fe5826ee..3f15cc03 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; @@ -26,6 +27,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -280,16 +282,54 @@ public class DocumentService { } // 1. Allgemeine Suche (für das Suchfeld im Frontend) - public List searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, DocumentStatus status) { + public List searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) { Specification spec = Specification.where(hasText(text)) .and(isBetween(from, to)) .and(hasSender(sender)) .and(hasReceiver(receiver)) .and(hasTags(tags)) + .and(hasTagPartial(tagQ)) .and(hasStatus(status)); - // Neueste zuerst (nach Erstellungsdatum) - return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt")); + Sort springSort = resolveSort(sort, dir); + if (sort == DocumentSort.RECEIVER) { + List results = documentRepository.findAll(spec); + return sortByFirstReceiver(results, dir); + } + return documentRepository.findAll(spec, springSort); + } + + private Sort resolveSort(DocumentSort sort, String dir) { + Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC; + if (sort == null || sort == DocumentSort.DATE) { + return Sort.by(direction, "documentDate"); + } + return switch (sort) { + case TITLE -> Sort.by(direction, "title"); + case SENDER -> Sort.by(direction, "sender.lastName").and(Sort.by(direction, "sender.firstName")); + case UPLOAD_DATE -> Sort.by(direction, "createdAt"); + default -> Sort.by(direction, "documentDate"); + }; + } + + private List sortByFirstReceiver(List documents, String dir) { + boolean ascending = "ASC".equalsIgnoreCase(dir); + Comparator nullSafeComparator = (a, b) -> { + if (a.isEmpty() && b.isEmpty()) return 0; + if (a.isEmpty()) return 1; + if (b.isEmpty()) return -1; + return ascending ? a.compareTo(b) : b.compareTo(a); + }; + return documents.stream() + .sorted(Comparator.comparing(this::firstReceiverSortKey, nullSafeComparator)) + .toList(); + } + + private String firstReceiverSortKey(Document doc) { + return doc.getReceivers().stream() + .min(Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName)) + .map(p -> p.getLastName() + " " + p.getFirstName()) + .orElse(""); } // 2. SPEZIALITÄT: Der Schriftwechsel diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 1166d873..50c31adb 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -58,7 +58,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_returns200_whenAuthenticated() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(Collections.emptyList()); mockMvc.perform(get("/api/documents/search")) @@ -68,13 +68,13 @@ class DocumentControllerTest { @Test @WithMockUser void search_withStatusParam_passesItToService() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED))) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any())) .thenReturn(Collections.emptyList()); mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) .andExpect(status().isOk()); - verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED)); + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()); } @Test @@ -84,6 +84,13 @@ class DocumentControllerTest { .andExpect(status().isBadRequest()); } + @Test + @WithMockUser + void search_withInvalidSort_returns400() throws Exception { + mockMvc.perform(get("/api/documents/search").param("sort", "GARBAGE")) + .andExpect(status().isBadRequest()); + } + // ─── POST /api/documents ───────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index fb01c7a2..5a11ba6d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1199,7 +1199,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) .thenReturn(List.of()); - documentService.searchDocuments(null, null, null, null, null, null, DocumentStatus.REVIEWED); + documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); } @@ -1209,7 +1209,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) .thenReturn(List.of()); - documentService.searchDocuments(null, null, null, null, null, null, null); + documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); } -- 2.49.1 From 879435c8d96b42015a81ab094767178aac650b26 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:17:08 +0200 Subject: [PATCH 03/26] feat(search): wrap search response in { documents, total } envelope Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 6 ++++-- .../familienarchiv/dto/DocumentSearchResult.java | 11 +++++++++++ .../controller/DocumentControllerTest.java | 12 ++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 57a0f437..b7834937 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -12,6 +12,7 @@ import java.util.UUID; import io.swagger.v3.oas.annotations.Parameter; +import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; @@ -187,7 +188,7 @@ public class DocumentController { } @GetMapping("/search") - public ResponseEntity> search( + public ResponseEntity search( @RequestParam(required = false) String q, @RequestParam(required = false) LocalDate from, @RequestParam(required = false) LocalDate to, @@ -198,7 +199,8 @@ public class DocumentController { @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status, @Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort, @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) { - return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir)); + List results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir); + return ResponseEntity.ok(DocumentSearchResult.of(results)); } // --- VERSIONS --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java new file mode 100644 index 00000000..9ac03cca --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java @@ -0,0 +1,11 @@ +package org.raddatz.familienarchiv.dto; + +import org.raddatz.familienarchiv.model.Document; + +import java.util.List; + +public record DocumentSearchResult(List documents, long total) { + public static DocumentSearchResult of(List documents) { + return new DocumentSearchResult(documents, documents.size()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 50c31adb..063dfa0a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -91,6 +91,18 @@ class DocumentControllerTest { .andExpect(status().isBadRequest()); } + @Test + @WithMockUser + void search_responseContainsTotalCount() throws Exception { + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/documents/search")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(0)) + .andExpect(jsonPath("$.documents").isArray()); + } + // ─── POST /api/documents ───────────────────────────────────────────────── @Test -- 2.49.1 From d1ad4d834c7987b27c2fe9c2090a8121123c5186 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:21:41 +0200 Subject: [PATCH 04/26] chore: regenerate API types with search envelope and new sort/tagQ params Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 417 +++++++++++++++++++++++++++++- 1 file changed, 410 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 52be38ff..36541dba 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -100,6 +100,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/transcription-blocks/{blockId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getBlock"]; + put: operations["updateBlock"]; + post?: never; + delete: operations["deleteBlock"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{documentId}/transcription-blocks/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["reorderBlocks"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users": { parameters: { query?: never; @@ -212,6 +244,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/transcription-blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listBlocks"]; + put?: never; + post: operations["createBlock"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{documentId}/transcription-blocks/{blockId}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getBlockComments"]; + put?: never; + post: operations["postBlockComment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["replyToBlockComment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/comments": { parameters: { query?: never; @@ -628,6 +708,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{documentId}/transcription-blocks/{blockId}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getBlockHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/search": { parameters: { query?: never; @@ -724,8 +820,6 @@ export interface paths { patch?: never; trace?: never; }; - // "/api/auth/reset-token-for-test" removed — @Operation(hidden=true) on AuthE2EController. - // Regenerate with `npm run generate:api` after the next backend build to keep in sync. "/api/admin/import-status": { parameters: { query?: never; @@ -876,6 +970,35 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; }; + UpdateTranscriptionBlockDTO: { + text?: string; + label?: string; + }; + TranscriptionBlock: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + annotationId: string; + /** Format: uuid */ + documentId: string; + text: string; + label?: string; + /** Format: int32 */ + sortOrder: number; + /** Format: int32 */ + version: number; + /** Format: uuid */ + createdBy?: string; + /** Format: uuid */ + updatedBy?: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + ReorderTranscriptionBlocksDTO: { + blockIds?: string[]; + }; CreateUserRequest: { username?: string; email?: string; @@ -895,6 +1018,20 @@ export interface components { name?: string; permissions?: string[]; }; + CreateTranscriptionBlockDTO: { + /** Format: int32 */ + pageNumber?: number; + /** Format: double */ + x?: number; + /** Format: double */ + y?: number; + /** Format: double */ + width?: number; + /** Format: double */ + height?: number; + text?: string; + label?: string; + }; CreateCommentDTO: { content?: string; mentionedUserIds?: string[]; @@ -907,6 +1044,8 @@ export interface components { /** Format: uuid */ annotationId?: string; /** Format: uuid */ + blockId?: string; + /** Format: uuid */ parentId?: string; /** Format: uuid */ authorId?: string; @@ -1038,18 +1177,18 @@ export interface components { /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - /** Format: int32 */ - numberOfElements?: number; first?: boolean; last?: boolean; + /** Format: int32 */ + numberOfElements?: number; empty?: boolean; }; PageableObject: { + paged?: boolean; /** Format: int32 */ pageNumber?: number; /** Format: int32 */ pageSize?: number; - paged?: boolean; /** Format: int64 */ offset?: number; sort?: components["schemas"]["SortObject"]; @@ -1085,6 +1224,22 @@ export interface components { snapshot: string; changedFields: string; }; + TranscriptionBlockVersion: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + blockId: string; + text: string; + /** Format: uuid */ + changedBy?: string; + /** Format: date-time */ + changedAt: string; + }; + DocumentSearchResult: { + documents?: components["schemas"]["Document"][]; + /** Format: int64 */ + total?: number; + }; IncompleteDocumentDTO: { /** Format: uuid */ id: string; @@ -1419,6 +1574,103 @@ export interface operations { }; }; }; + getBlock: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + blockId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlock"]; + }; + }; + }; + }; + updateBlock: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + blockId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTranscriptionBlockDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlock"]; + }; + }; + }; + }; + deleteBlock: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + blockId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + reorderBlocks: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReorderTranscriptionBlocksDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlock"][]; + }; + }; + }; + }; getAllUsers: { parameters: { query?: never; @@ -1643,6 +1895,130 @@ export interface operations { }; }; }; + listBlocks: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlock"][]; + }; + }; + }; + }; + createBlock: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTranscriptionBlockDTO"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlock"]; + }; + }; + }; + }; + getBlockComments: { + parameters: { + query?: never; + header?: never; + path: { + blockId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DocumentComment"][]; + }; + }; + }; + }; + postBlockComment: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + blockId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateCommentDTO"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DocumentComment"]; + }; + }; + }; + }; + replyToBlockComment: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + commentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateCommentDTO"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DocumentComment"]; + }; + }; + }; + }; getDocumentComments: { parameters: { query?: never; @@ -2356,6 +2732,29 @@ export interface operations { }; }; }; + getBlockHistory: { + parameters: { + query?: never; + header?: never; + path: { + documentId: string; + blockId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TranscriptionBlockVersion"][]; + }; + }; + }; + }; search_1: { parameters: { query?: { @@ -2365,8 +2764,13 @@ export interface operations { senderId?: string; receiverId?: string; tag?: string[]; + tagQ?: string; /** @description Filter by document status */ status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; + /** @description Sort field */ + sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE"; + /** @description Sort direction: ASC or DESC */ + dir?: string; }; header?: never; path?: never; @@ -2380,7 +2784,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Document"][]; + "*/*": components["schemas"]["DocumentSearchResult"]; }; }; }; @@ -2500,7 +2904,6 @@ export interface operations { }; }; }; - // getResetTokenForTest removed — @Operation(hidden=true) on AuthE2EController. importStatus: { parameters: { query?: never; -- 2.49.1 From 2c0748d60e8e4c45ac106e12e19f52fcc13b5191 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:23:14 +0200 Subject: [PATCH 05/26] feat(utils): add debounce utility with full test coverage Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/utils/debounce.spec.ts | 69 +++++++++++++++++++++++++ frontend/src/lib/utils/debounce.ts | 12 +++++ 2 files changed, 81 insertions(+) create mode 100644 frontend/src/lib/utils/debounce.spec.ts create mode 100644 frontend/src/lib/utils/debounce.ts diff --git a/frontend/src/lib/utils/debounce.spec.ts b/frontend/src/lib/utils/debounce.spec.ts new file mode 100644 index 00000000..6ee1c067 --- /dev/null +++ b/frontend/src/lib/utils/debounce.spec.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debounce } from './debounce'; + +describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not fire before the delay has elapsed', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(199); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('fires exactly once after the delay', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('resets the timer on each call — fires only once after inactivity', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(100); + debounced(); + vi.advanceTimersByTime(100); + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('passes the latest arguments to the callback', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced('first'); + debounced('second'); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledWith('second'); + }); + + it('can fire again after the first invocation settles', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(200); + debounced(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/lib/utils/debounce.ts b/frontend/src/lib/utils/debounce.ts new file mode 100644 index 00000000..b8958e45 --- /dev/null +++ b/frontend/src/lib/utils/debounce.ts @@ -0,0 +1,12 @@ +/** + * Returns a debounced version of fn that delays invocation until after + * `delay` ms have elapsed since the last call. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function debounce void>(fn: T, delay: number): T { + let timer: ReturnType; + return ((...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }) as T; +} -- 2.49.1 From 3f8f3cd9382ec9a3251062bc3e26f389bb3848c6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:35:46 +0200 Subject: [PATCH 06/26] feat(i18n): add sort, result count, and empty-state translation keys Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 10 +++++++++- frontend/messages/en.json | 10 +++++++++- frontend/messages/es.json | 10 +++++++++- frontend/src/routes/page.svelte.spec.ts | 6 +++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 860891f4..33640306 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -52,7 +52,15 @@ "login_label_username": "Benutzername", "login_label_password": "Passwort", "login_btn_submit": "Anmelden", - "docs_search_placeholder": "Suche in Titel, Inhalt, Ort...", + "docs_search_placeholder": "Titel, Personen, Tags durchsuchen…", + "docs_sort_label": "Sortierung", + "docs_sort_date": "Datum", + "docs_sort_title": "Titel", + "docs_sort_sender": "Absender", + "docs_sort_receiver": "Empfänger", + "docs_sort_upload": "Hochgeladen", + "docs_result_count": "{count} Dokumente", + "docs_empty_for_term": "Keine Dokumente f\u00fcr \"{term}\" gefunden", "docs_btn_filter": "Filter", "docs_btn_reset_title": "Filter zurücksetzen", "docs_filter_label_tags": "Schlagworte", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 110d0c13..14db1d3a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -52,7 +52,15 @@ "login_label_username": "Username", "login_label_password": "Password", "login_btn_submit": "Sign in", - "docs_search_placeholder": "Search in title, content, location...", + "docs_search_placeholder": "Search title, people, tags…", + "docs_sort_label": "Sort", + "docs_sort_date": "Date", + "docs_sort_title": "Title", + "docs_sort_sender": "Sender", + "docs_sort_receiver": "Receiver", + "docs_sort_upload": "Uploaded", + "docs_result_count": "{count} documents", + "docs_empty_for_term": "No documents found for \"{term}\"", "docs_btn_filter": "Filter", "docs_btn_reset_title": "Reset filter", "docs_filter_label_tags": "Tags", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 8ac74c35..71fad278 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -52,7 +52,15 @@ "login_label_username": "Usuario", "login_label_password": "Contraseña", "login_btn_submit": "Iniciar sesión", - "docs_search_placeholder": "Buscar en título, contenido, lugar...", + "docs_search_placeholder": "Buscar título, personas, etiquetas…", + "docs_sort_label": "Ordenar", + "docs_sort_date": "Fecha", + "docs_sort_title": "Título", + "docs_sort_sender": "Remitente", + "docs_sort_receiver": "Destinatario", + "docs_sort_upload": "Subido", + "docs_result_count": "{count} documentos", + "docs_empty_for_term": "No se encontraron documentos para \"{term}\"", "docs_btn_filter": "Filtrar", "docs_btn_reset_title": "Restablecer filtro", "docs_filter_label_tags": "Etiquetas", diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index 8a280c1a..a8c31e67 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -56,7 +56,7 @@ describe('Home page – search bar', () => { it('renders the full-text search input', async () => { render(Page, { data: emptyData }); await expect - .element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')) + .element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026')) .toBeInTheDocument(); await page.screenshot({ path: 'test-results/screenshots/home-default.png' }); }); @@ -79,7 +79,7 @@ describe('Home page – search bar', () => { it('pre-fills the search input from filters.q', async () => { render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } }); await expect - .element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')) + .element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026')) .toHaveValue('Urlaub'); }); }); @@ -178,7 +178,7 @@ describe('Home page – search input keystroke preservation', () => { it('does not overwrite the search input while the user is focused and stale data arrives', async () => { const { rerender } = render(Page, { data: emptyData }); - const input = page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'); + const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'); // User types "abc" — input is focused await input.click(); -- 2.49.1 From aeed6e0dac00d45ea5a880be97a56b83c7a27ff5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:39:26 +0200 Subject: [PATCH 07/26] feat(search): add SortDropdown component with direction toggle Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/SortDropdown.svelte | 36 +++++++++++++++++++ .../components/SortDropdown.svelte.spec.ts | 36 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 frontend/src/lib/components/SortDropdown.svelte create mode 100644 frontend/src/lib/components/SortDropdown.svelte.spec.ts diff --git a/frontend/src/lib/components/SortDropdown.svelte b/frontend/src/lib/components/SortDropdown.svelte new file mode 100644 index 00000000..abc7012b --- /dev/null +++ b/frontend/src/lib/components/SortDropdown.svelte @@ -0,0 +1,36 @@ + + +
+ + +
diff --git a/frontend/src/lib/components/SortDropdown.svelte.spec.ts b/frontend/src/lib/components/SortDropdown.svelte.spec.ts new file mode 100644 index 00000000..2c4282a8 --- /dev/null +++ b/frontend/src/lib/components/SortDropdown.svelte.spec.ts @@ -0,0 +1,36 @@ +import { render } from 'vitest-browser-svelte'; +import { describe, expect, it } from 'vitest'; +import { page } from '@vitest/browser/context'; +import SortDropdown from './SortDropdown.svelte'; + +describe('SortDropdown', () => { + it('renders a select with all sort options', async () => { + render(SortDropdown, { sort: 'DATE', dir: 'desc' }); + const select = page.getByRole('combobox'); + await expect.element(select).toBeInTheDocument(); + }); + + it('shows the current sort value as selected', async () => { + render(SortDropdown, { sort: 'TITLE', dir: 'asc' }); + const select = page.getByRole('combobox'); + await expect.element(select).toHaveValue('TITLE'); + }); + + it('renders direction toggle button', async () => { + render(SortDropdown, { sort: 'DATE', dir: 'asc' }); + const btn = page.getByRole('button'); + await expect.element(btn).toBeInTheDocument(); + }); + + it('direction button shows ↑ when dir is asc', async () => { + render(SortDropdown, { sort: 'DATE', dir: 'asc' }); + const btn = page.getByRole('button'); + await expect.element(btn).toHaveTextContent('↑'); + }); + + it('direction button shows ↓ when dir is desc', async () => { + render(SortDropdown, { sort: 'DATE', dir: 'desc' }); + const btn = page.getByRole('button'); + await expect.element(btn).toHaveTextContent('↓'); + }); +}); -- 2.49.1 From eeb78c98ec655716dd9eb42ee5932431ea199f38 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:42:08 +0200 Subject: [PATCH 08/26] feat(search): add onTextInput callback to TagInput for live tag filter Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/TagInput.svelte | 5 +++-- .../lib/components/TagInput.svelte.spec.ts | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 369bfec9..79aef870 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -6,9 +6,10 @@ import { clickOutside } from '$lib/actions/clickOutside'; interface Props { tags?: string[]; allowCreation?: boolean; + onTextInput?: (text: string) => void; } -let { tags = $bindable([]), allowCreation = true }: Props = $props(); +let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props(); let inputVal = $state(''); let suggestions: string[] = $state([]); @@ -101,7 +102,7 @@ function handleKeydown(e: KeyboardEvent) { fetchSuggestions(inputVal)} + oninput={() => { fetchSuggestions(inputVal); onTextInput?.(inputVal); }} onkeydown={handleKeydown} onfocus={() => fetchSuggestions(inputVal)} placeholder={tags.length === 0 diff --git a/frontend/src/lib/components/TagInput.svelte.spec.ts b/frontend/src/lib/components/TagInput.svelte.spec.ts index e0f78da9..9456d06e 100644 --- a/frontend/src/lib/components/TagInput.svelte.spec.ts +++ b/frontend/src/lib/components/TagInput.svelte.spec.ts @@ -208,3 +208,24 @@ describe('TagInput – autocomplete', () => { await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument(); }); }); + +// ─── onTextInput callback ────────────────────────────────────────────────────── + +describe('TagInput – onTextInput callback', () => { + it('calls onTextInput with the current value on every input event', async () => { + mockFetchEmpty(); + const onTextInput = vi.fn(); + render(TagInput, { tags: [], allowCreation: false, onTextInput }); + const input = page.getByRole('textbox'); + await input.fill('fa'); + await expect.poll(() => onTextInput.mock.calls.length).toBeGreaterThan(0); + expect(onTextInput).toHaveBeenCalledWith('fa'); + }); + + it('does not throw when onTextInput is not provided', async () => { + mockFetchEmpty(); + render(TagInput, { tags: [], allowCreation: false }); + const input = page.getByRole('textbox'); + await expect(input.fill('fa')).resolves.not.toThrow(); + }); +}); -- 2.49.1 From 4fe10e131648c83b665a61818f48a6cf69752e37 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 13:50:45 +0200 Subject: [PATCH 09/26] feat(search): add sort/dir/tagQ props to SearchFilterBar with SortDropdown Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/SearchFilterBar.svelte | 33 ++++++++++++- .../src/routes/SearchFilterBar.svelte.spec.ts | 46 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/SearchFilterBar.svelte.spec.ts diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index a0d8bef6..ce2d40cb 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -1,6 +1,7 @@
@@ -58,6 +79,9 @@ let {
+ + + -- 2.49.1 From 1f86e6e2389a99e6642b68d3c50069fc776863a5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 16:50:00 +0200 Subject: [PATCH 25/26] fix(a11y): bump result count text to text-base (16px) for senior readability Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/DocumentList.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index c0d9d2dc..ef99ac55 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -47,7 +47,7 @@ let { {#if total > 0} -

{m.docs_result_count({ count: total })}

+

{m.docs_result_count({ count: total })}

{/if} -- 2.49.1 From a863f8baad4752b528c675dc669ef9a628cefefe Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 16:50:52 +0200 Subject: [PATCH 26/26] docs(search): explain void sort/dir ESLint workaround in SearchFilterBar Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/SearchFilterBar.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index a7251202..589476df 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -45,7 +45,8 @@ let { let sortDirMounted = false; $effect(() => { - // Track sort and dir so this effect re-runs when either changes + // Read sort and dir so Svelte tracks them as dependencies of this effect. + // `void` suppresses the ESLint no-unused-expressions rule for bare variable reads. void sort; void dir; if (!sortDirMounted) { -- 2.49.1