From cb02dc84f61afe25e5bbdea1062a93de3f7bb74b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:33:27 +0200 Subject: [PATCH 01/49] feat(user): add deterministic avatar color to AppUser Adds color field assigned from an 8-colour palette keyed on the user's UUID hash (Math.abs(id.hashCode()) % 8). Fires via @PrePersist/@PreUpdate/@PostLoad so both new and existing users get the correct colour at runtime. V47 migration adds the column and fixes the V46 REVOKE bug that hardcoded role name 'app_user' instead of CURRENT_USER. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/model/AppUser.java | 26 +++++++++++++ .../db/migration/V47__add_user_color.sql | 8 ++++ .../familienarchiv/model/AppUserTest.java | 38 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V47__add_user_color.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java index 3ac33625..6b2b8419 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java @@ -19,6 +19,10 @@ import java.util.HashSet; import java.util.Set; import java.util.UUID; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; + @Entity @Table(name = "users") @Data @@ -74,6 +78,28 @@ public class AppUser { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createdAt; + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String color = ""; + + private static final String[] PALETTE = { + "#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8" + }; + + public static String computeColor(UUID id) { + return PALETTE[Math.abs(id.hashCode()) % PALETTE.length]; + } + + @PrePersist + @PreUpdate + @PostLoad + void deriveColor() { + if (id != null && (color == null || color.isEmpty())) { + this.color = computeColor(id); + } + } + public boolean hasPermission(String permission) { if (groups == null || groups.isEmpty()) { return false; diff --git a/backend/src/main/resources/db/migration/V47__add_user_color.sql b/backend/src/main/resources/db/migration/V47__add_user_color.sql new file mode 100644 index 00000000..cca60905 --- /dev/null +++ b/backend/src/main/resources/db/migration/V47__add_user_color.sql @@ -0,0 +1,8 @@ +-- Add deterministic avatar color to app_users. +-- Assigned at application layer (AppUser.java) from a fixed 8-colour palette. +-- Also corrects V46's REVOKE which hardcoded 'app_user' instead of CURRENT_USER. + +ALTER TABLE app_users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT ''; + +-- Fix V46 append-only enforcement for the actual application role. +REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java new file mode 100644 index 00000000..8fec24fd --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java @@ -0,0 +1,38 @@ +package org.raddatz.familienarchiv.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppUserTest { + + private static final List EXPECTED_PALETTE = List.of( + "#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8" + ); + + @Test + void computeColor_returnsDeterministicPaletteColor() { + UUID id = UUID.fromString("12345678-1234-1234-1234-123456789abc"); + String color = AppUser.computeColor(id); + assertThat(EXPECTED_PALETTE).contains(color); + assertThat(AppUser.computeColor(id)).isEqualTo(color); + } + + @Test + void computeColor_isStableAcrossCalls() { + UUID id = UUID.randomUUID(); + assertThat(AppUser.computeColor(id)).isEqualTo(AppUser.computeColor(id)); + } + + @Test + void computeColor_variesAcrossDifferentIds() { + long distinct = java.util.stream.IntStream.range(0, 100) + .mapToObj(i -> AppUser.computeColor(UUID.randomUUID())) + .distinct() + .count(); + assertThat(distinct).isGreaterThan(1); + } +} -- 2.49.1 From b3013c42c0af4de3a2f3dca087a400e4eb302652 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:36:02 +0200 Subject: [PATCH 02/49] fix(audit): add blockId to TEXT_SAVED audit payload Required for dashboard Pulse stat 2 (COUNT DISTINCT blockId). Without it, two saves on different blocks on the same page were indistinguishable. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/service/TranscriptionService.java | 3 ++- .../familienarchiv/service/TranscriptionServiceTest.java | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index c52cf103..01bbff54 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -142,7 +142,8 @@ public class TranscriptionService { if (!text.equals(previousText)) { Optional annotation = annotationRepository.findById(block.getAnnotationId()); int pageNumber = annotation.map(DocumentAnnotation::getPageNumber).orElse(0); - auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, Map.of("pageNumber", pageNumber)); + auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, + Map.of("pageNumber", pageNumber, "blockId", saved.getId().toString())); } Document doc = documentService.getDocumentById(documentId); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index e9d415a8..65584fe7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -487,6 +487,7 @@ class TranscriptionServiceTest { org.mockito.ArgumentMatchers.eq(docId), payloadCaptor.capture()); assertThat(payloadCaptor.getValue()).containsEntry("pageNumber", 3); + assertThat(payloadCaptor.getValue()).containsEntry("blockId", blockId.toString()); } @Test -- 2.49.1 From 19832dc1e0e05d9ed58cc14949ac74dcd55ce638 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:39:41 +0200 Subject: [PATCH 03/49] refactor(security): extract requireUserId to SecurityUtils Both DocumentController and TranscriptionBlockController contained identical private requireUserId helpers. Extracted to a shared static utility in the security package ahead of DashboardController which also needs actor resolution. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 10 +--- .../TranscriptionBlockController.java | 12 +--- .../security/SecurityUtils.java | 24 ++++++++ .../security/SecurityUtilsTest.java | 58 +++++++++++++++++++ 4 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.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 e29e164f..493fa046 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -28,6 +28,7 @@ import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.security.SecurityUtils; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.FileService; @@ -286,13 +287,6 @@ public class DocumentController { } private UUID requireUserId(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - throw DomainException.unauthorized("Authentication required"); - } - AppUser user = userService.findByEmail(authentication.getName()); - if (user == null) { - throw DomainException.unauthorized("User not found"); - } - return user.getId(); + return SecurityUtils.requireUserId(authentication, userService); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java index 7b36cd26..82338bc2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -5,12 +5,11 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.security.SecurityUtils; import org.raddatz.familienarchiv.service.TranscriptionService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.http.HttpStatus; @@ -100,13 +99,6 @@ public class TranscriptionBlockController { } private UUID requireUserId(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - throw DomainException.unauthorized("Authentication required"); - } - AppUser user = userService.findByEmail(authentication.getName()); - if (user == null) { - throw DomainException.unauthorized("User not found"); - } - return user.getId(); + return SecurityUtils.requireUserId(authentication, userService); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java new file mode 100644 index 00000000..dd5dceb7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java @@ -0,0 +1,24 @@ +package org.raddatz.familienarchiv.security; + +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.security.core.Authentication; + +import java.util.UUID; + +public final class SecurityUtils { + + private SecurityUtils() {} + + public static UUID requireUserId(Authentication authentication, UserService userService) { + if (authentication == null || !authentication.isAuthenticated()) { + throw DomainException.unauthorized("Authentication required"); + } + AppUser user = userService.findByEmail(authentication.getName()); + if (user == null) { + throw DomainException.unauthorized("User not found"); + } + return user.getId(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.java new file mode 100644 index 00000000..78f0dc24 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.java @@ -0,0 +1,58 @@ +package org.raddatz.familienarchiv.security; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.security.core.Authentication; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityUtilsTest { + + @Mock Authentication authentication; + @Mock UserService userService; + + @Test + void requireUserId_throwsUnauthorized_whenAuthenticationIsNull() { + assertThatThrownBy(() -> SecurityUtils.requireUserId(null, userService)) + .isInstanceOf(DomainException.class); + } + + @Test + void requireUserId_throwsUnauthorized_whenNotAuthenticated() { + when(authentication.isAuthenticated()).thenReturn(false); + assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService)) + .isInstanceOf(DomainException.class); + } + + @Test + void requireUserId_throwsUnauthorized_whenUserNotFound() { + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("ghost@example.com"); + when(userService.findByEmail("ghost@example.com")).thenReturn(null); + assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService)) + .isInstanceOf(DomainException.class); + } + + @Test + void requireUserId_returnsUserId_whenAuthenticated() { + UUID userId = UUID.randomUUID(); + AppUser user = AppUser.builder().id(userId).email("user@example.com").password("pw").build(); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("user@example.com"); + when(userService.findByEmail("user@example.com")).thenReturn(user); + + UUID result = SecurityUtils.requireUserId(authentication, userService); + + assertThat(result).isEqualTo(userId); + } +} -- 2.49.1 From c678432d2585d9afbfe4fd0aedede27ea313bf53 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:58:04 +0200 Subject: [PATCH 04/49] =?UTF-8?q?fix(migration):=20correct=20app=5Fusers?= =?UTF-8?q?=20=E2=86=92=20users=20table=20references=20in=20V46/V47?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppUser entity is mapped to the 'users' table (not 'app_users'). V46 had a broken REFERENCES clause and hardcoded role in REVOKE; V47 and the native query in AuditLogQueryRepository had the same wrong table name. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/AuditLogQueryRepository.java | 88 +++++++++++++++++++ .../db/migration/V46__add_audit_log.sql | 4 +- .../db/migration/V47__add_user_color.sql | 2 +- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java new file mode 100644 index 00000000..83d9a627 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java @@ -0,0 +1,88 @@ +package org.raddatz.familienarchiv.dashboard; + +import org.raddatz.familienarchiv.audit.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AuditLogQueryRepository extends JpaRepository { + + @Query(value = """ + SELECT a.document_id + FROM audit_log a + WHERE a.kind = 'TEXT_SAVED' + AND a.actor_id = :userId + AND a.document_id IS NOT NULL + ORDER BY a.happened_at DESC + LIMIT 1 + """, nativeQuery = true) + Optional findMostRecentDocumentIdByActor(@Param("userId") UUID userId); + + @Query(value = """ + SELECT * FROM ( + SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at)) + a.kind AS kind, + a.actor_id AS actorId, + CASE + WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL + THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1)) + WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1)) + WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1)) + ELSE '?' + END AS actorInitials, + COALESCE(u.color, '') AS actorColor, + CONCAT_WS(' ', u.first_name, u.last_name) AS actorName, + a.document_id AS documentId, + a.happened_at AS happenedAt, + (a.kind = 'MENTION_CREATED' + AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_id + WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED') + AND a.document_id IS NOT NULL + ORDER BY a.actor_id, a.document_id, a.kind, + date_trunc('hour', a.happened_at), a.happened_at DESC + ) deduped + ORDER BY happened_at DESC + LIMIT :limit + """, nativeQuery = true) + List findDedupedActivityFeed( + @Param("currentUserId") String currentUserId, + @Param("limit") int limit); + + @Query(value = """ + SELECT + COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages, + COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated, + COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed, + COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded, + COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) + FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED') + AND a.actor_id::text = :userId) AS yourPages + FROM audit_log a + WHERE a.happened_at >= :weekStart + AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED') + """, nativeQuery = true) + PulseStatsRow getPulseStats( + @Param("weekStart") OffsetDateTime weekStart, + @Param("userId") String userId); + + @Query(value = """ + SELECT DISTINCT ON (a.document_id) + a.document_id AS documentId, + a.actor_id AS actorId + FROM audit_log a + WHERE a.kind = :kind + AND a.document_id IN :documentIds + AND a.actor_id IS NOT NULL + ORDER BY a.document_id, a.happened_at DESC + """, nativeQuery = true) + List findMostRecentActorPerDocument( + @Param("documentIds") List documentIds, + @Param("kind") String kind); +} diff --git a/backend/src/main/resources/db/migration/V46__add_audit_log.sql b/backend/src/main/resources/db/migration/V46__add_audit_log.sql index 2a01126a..645e1d43 100644 --- a/backend/src/main/resources/db/migration/V46__add_audit_log.sql +++ b/backend/src/main/resources/db/migration/V46__add_audit_log.sql @@ -6,7 +6,7 @@ CREATE TABLE audit_log ( happened_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- ON DELETE SET NULL is by design: GDPR right-to-erasure. Deleted users' events -- retain their timestamp and kind but lose actor attribution. - actor_id UUID REFERENCES app_users(id) ON DELETE SET NULL, + actor_id UUID REFERENCES users(id) ON DELETE SET NULL, kind VARCHAR(50) NOT NULL, document_id UUID REFERENCES documents(id) ON DELETE CASCADE, payload JSONB @@ -19,4 +19,4 @@ CREATE INDEX idx_audit_log_kind ON audit_log (kind); -- Enforce append-only at the database layer: the application role may INSERT -- but must not UPDATE or DELETE audit rows. -REVOKE UPDATE, DELETE ON audit_log FROM app_user; +REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER; diff --git a/backend/src/main/resources/db/migration/V47__add_user_color.sql b/backend/src/main/resources/db/migration/V47__add_user_color.sql index cca60905..ac6317f7 100644 --- a/backend/src/main/resources/db/migration/V47__add_user_color.sql +++ b/backend/src/main/resources/db/migration/V47__add_user_color.sql @@ -2,7 +2,7 @@ -- Assigned at application layer (AppUser.java) from a fixed 8-colour palette. -- Also corrects V46's REVOKE which hardcoded 'app_user' instead of CURRENT_USER. -ALTER TABLE app_users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT ''; -- Fix V46 append-only enforcement for the actual application role. REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER; -- 2.49.1 From 9e0b72bc10680639cd7f5070086519c835a498ed Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:05:14 +0200 Subject: [PATCH 05/49] feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints GET /api/documents/incomplete and GET /api/documents/recent-activity are superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 13 ---- .../controller/DocumentControllerTest.java | 72 +++---------------- 2 files changed, 8 insertions(+), 77 deletions(-) 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 493fa046..bbef336d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -17,7 +17,6 @@ import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.TagOperator; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; -import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; @@ -198,12 +197,6 @@ public class DocumentController { return Map.of("count", documentService.getIncompleteCount()); } - @GetMapping("/incomplete") - public List getIncomplete( - @Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) { - return documentService.findIncompleteDocuments(size); - } - @GetMapping("/incomplete/next") public ResponseEntity getNextIncomplete(@RequestParam UUID excludeId) { return documentService.findNextIncompleteDocument(excludeId) @@ -211,12 +204,6 @@ public class DocumentController { .orElse(ResponseEntity.noContent().build()); } - @GetMapping("/recent-activity") - public ResponseEntity> getRecentActivity( - @RequestParam(defaultValue = "5") int size) { - return ResponseEntity.ok(documentService.getRecentActivity(size)); - } - @GetMapping("/search") public ResponseEntity search( @RequestParam(required = false) String q, 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 36b86449..f8aaf5dd 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; -import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; @@ -390,47 +389,14 @@ class DocumentControllerTest { .andExpect(jsonPath("$.count").value(3)); } - // ─── GET /api/documents/incomplete ─────────────────────────────────────── - - @Test - void getIncomplete_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(get("/api/documents/incomplete")) - .andExpect(status().isUnauthorized()); - } + // ─── GET /api/documents/incomplete (removed — superseded by dashboard) ──── @Test @WithMockUser - void getIncomplete_returns200_withDTOList() throws Exception { - UUID id = UUID.randomUUID(); - IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig"); - when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto)); - + void getIncomplete_endpointRemoved() throws Exception { + // The path hits /{id} and fails UUID conversion — not a 200 anymore mockMvc.perform(get("/api/documents/incomplete")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(id.toString())) - .andExpect(jsonPath("$[0].title").value("Unvollständig")); - } - - @Test - @WithMockUser - void getIncomplete_withSizeParam_passesItToService() throws Exception { - when(documentService.findIncompleteDocuments(5)).thenReturn(List.of()); - - mockMvc.perform(get("/api/documents/incomplete").param("size", "5")) - .andExpect(status().isOk()); - - verify(documentService).findIncompleteDocuments(5); - } - - @Test - @WithMockUser - void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception { - when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of()); - - mockMvc.perform(get("/api/documents/incomplete")) - .andExpect(status().isOk()); - - verify(documentService).findIncompleteDocuments(10); + .andExpect(status().is4xxClientError()); } // ─── GET /api/documents/incomplete/next ────────────────────────────────── @@ -467,36 +433,14 @@ class DocumentControllerTest { .andExpect(status().isNoContent()); } - // ─── GET /api/documents/recent-activity ────────────────────────────────── - - @Test - void getRecentActivity_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(get("/api/documents/recent-activity")) - .andExpect(status().isUnauthorized()); - } + // ─── GET /api/documents/recent-activity (removed — superseded by dashboard) @Test @WithMockUser - void getRecentActivity_returnsOkWithDocuments() throws Exception { - Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build(); - Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build(); - when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2)); - - mockMvc.perform(get("/api/documents/recent-activity").param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("Alpha")) - .andExpect(jsonPath("$[1].title").value("Beta")); - } - - @Test - @WithMockUser - void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception { - when(documentService.getRecentActivity(5)).thenReturn(List.of()); - + void getRecentActivity_endpointRemoved() throws Exception { + // The path hits /{id} and fails UUID conversion — not a 200 anymore mockMvc.perform(get("/api/documents/recent-activity")) - .andExpect(status().isOk()); - - verify(documentService).getRecentActivity(5); + .andExpect(status().is4xxClientError()); } // ─── GET /api/documents/{id}/versions ──────────────────────────────────── -- 2.49.1 From 714f00ef9db9931535cfe79bdcf3675e1243916d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:10:50 +0200 Subject: [PATCH 06/49] chore(types): regenerate API types with dashboard endpoints Adds DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO, ActivityActorDTO and the three /api/dashboard/* paths. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 569 +++++++++++++++++++++++------- 1 file changed, 445 insertions(+), 124 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index ba57d088..cde59425 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -324,6 +324,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listInvites"]; + put?: never; + post: operations["createInvite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/groups": { parameters: { query?: never; @@ -356,6 +372,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{id}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDocumentFile"]; + put?: never; + post: operations["attachFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/transcription-blocks": { parameters: { query?: never; @@ -532,6 +564,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/forgot-password": { parameters: { query?: never; @@ -1044,22 +1092,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/{id}/file": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getDocumentFile"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/documents/{documentId}/transcription-blocks/{blockId}/history": { parameters: { query?: never; @@ -1108,38 +1140,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/recent-activity": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getRecentActivity"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/documents/incomplete": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getIncomplete"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/documents/incomplete/next": { parameters: { query?: never; @@ -1188,6 +1188,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/dashboard/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getResume"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dashboard/pulse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPulse"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dashboard/activity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getActivity"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/invite/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getInvitePrefill"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/import-status": { parameters: { query?: never; @@ -1236,6 +1300,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/invites/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["revokeInvite"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1253,12 +1333,13 @@ export interface components { AppUser: { /** Format: uuid */ id: string; + /** Format: email */ + email: string; password?: string; firstName?: string; lastName?: string; /** Format: date */ birthDate?: string; - email: string; contact?: string; enabled: boolean; notifyOnReply: boolean; @@ -1266,6 +1347,7 @@ export interface components { groups: components["schemas"]["UserGroup"][]; /** Format: date-time */ createdAt: string; + color: string; }; UserGroup: { /** Format: uuid */ @@ -1405,6 +1487,7 @@ export interface components { blockIds?: string[]; }; CreateUserRequest: { + /** Format: email */ email: string; initialPassword?: string; groupIds?: string[]; @@ -1470,11 +1553,40 @@ export interface components { }; TriggerSenderTrainingDTO: { /** Format: uuid */ - personId?: string; + personId: string; }; BatchOcrDTO: { documentIds: string[]; }; + CreateInviteRequest: { + label?: string; + /** Format: int32 */ + maxUses?: number; + prefillFirstName?: string; + prefillLastName?: string; + prefillEmail?: string; + groupIds?: string[]; + /** Format: date-time */ + expiresAt?: string; + }; + InviteListItemDTO: { + /** Format: uuid */ + id: string; + code: string; + displayCode: string; + label?: string; + /** Format: int32 */ + useCount: number; + /** Format: int32 */ + maxUses?: number; + /** Format: date-time */ + expiresAt?: string; + revoked: boolean; + status: string; + /** Format: date-time */ + createdAt: string; + shareableUrl?: string; + }; GroupDTO: { name?: string; permissions?: string[]; @@ -1580,6 +1692,15 @@ export interface components { token?: string; newPassword?: string; }; + RegisterRequest: { + code: string; + /** Format: email */ + email: string; + password: string; + firstName?: string; + lastName?: string; + notifyOnMention?: boolean; + }; ForgotPasswordRequest: { email?: string; }; @@ -1754,6 +1875,8 @@ export interface components { /** Format: int64 */ totalElements?: number; pageable?: components["schemas"]["PageableObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -1762,8 +1885,6 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; - first?: boolean; - last?: boolean; empty?: boolean; }; PageableObject: { @@ -1847,10 +1968,54 @@ export interface components { summarySnippet?: string; summaryOffsets: components["schemas"]["MatchOffset"][]; }; - IncompleteDocumentDTO: { + ActivityActorDTO: { + initials: string; + color: string; + name?: string; + }; + DashboardResumeDTO: { /** Format: uuid */ - id: string; + documentId: string; title: string; + caption: string; + excerpt: string; + /** Format: int32 */ + page: number; + /** Format: int32 */ + pages: number; + /** Format: int32 */ + pct: number; + thumbnailUrl?: string; + collaborators: components["schemas"]["ActivityActorDTO"][]; + }; + DashboardPulseDTO: { + /** Format: int32 */ + pages: number; + /** Format: int32 */ + annotated: number; + /** Format: int32 */ + transcribed: number; + /** Format: int32 */ + uploaded: number; + /** Format: int32 */ + yourPages: number; + contributors: components["schemas"]["ActivityActorDTO"][]; + }; + ActivityFeedItemDTO: { + /** @enum {string} */ + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED"; + actor?: components["schemas"]["ActivityActorDTO"]; + /** Format: uuid */ + documentId: string; + documentTitle: string; + /** Format: date-time */ + happenedAt: string; + youMentioned: boolean; + }; + InvitePrefillDTO: { + firstName: string; + lastName: string; + email: string; }; }; responses: never; @@ -2619,6 +2784,52 @@ export interface operations { }; }; }; + listInvites: { + parameters: { + query?: { + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InviteListItemDTO"][]; + }; + }; + }; + }; + createInvite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateInviteRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InviteListItemDTO"]; + }; + }; + }; + }; getAllGroups: { parameters: { query?: never; @@ -2687,6 +2898,57 @@ export interface operations { }; }; }; + getDocumentFile: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + attachFile: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Document"]; + }; + }; + }; + }; listBlocks: { parameters: { query?: never; @@ -3086,6 +3348,30 @@ export interface operations { }; }; }; + register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AppUser"]; + }; + }; + }; + }; forgotPassword: { parameters: { query?: never; @@ -3603,9 +3889,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": { - [key: string]: unknown; - }; + "*/*": components["schemas"]["TrainingInfoResponse"]; }; }; }; @@ -3850,28 +4134,6 @@ export interface operations { }; }; }; - getDocumentFile: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": string; - }; - }; - }; - }; getBlockHistory: { parameters: { query?: never; @@ -3953,51 +4215,6 @@ export interface operations { }; }; }; - getRecentActivity: { - parameters: { - query?: { - size?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["Document"][]; - }; - }; - }; - }; - getIncomplete: { - parameters: { - query?: { - /** @description Maximum number of results */ - size?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["IncompleteDocumentDTO"][]; - }; - }; - }; - }; getNextIncomplete: { parameters: { query: { @@ -4068,6 +4285,90 @@ export interface operations { }; }; }; + getResume: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DashboardResumeDTO"]; + }; + }; + }; + }; + getPulse: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DashboardPulseDTO"]; + }; + }; + }; + }; + getActivity: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ActivityFeedItemDTO"][]; + }; + }; + }; + }; + getInvitePrefill: { + parameters: { + query?: never; + header?: never; + path: { + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InvitePrefillDTO"]; + }; + }; + }; + }; importStatus: { parameters: { query?: never; @@ -4129,4 +4430,24 @@ export interface operations { }; }; }; + revokeInvite: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } -- 2.49.1 From 99247ed58d730f94a73e7b6391aea92c0fd76e0d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:13:57 +0200 Subject: [PATCH 07/49] feat(i18n): add dashboard i18n keys (de/en/es) Greeting, resume card, mission control, family pulse, activity feed, audit action verbs, and dropzone keys for the Issue #271 dashboard. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 43 ++++++++++++++++++++++++++++++++++++++- frontend/messages/en.json | 43 ++++++++++++++++++++++++++++++++++++++- frontend/messages/es.json | 43 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 920886a2..fcaf1fc0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -706,5 +706,46 @@ "admin_new_invite_expires": "Ablaufdatum (optional)", "admin_invite_created_title": "Einladung erstellt", "admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:", - "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?" + "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?", + + "greeting_morning": "Guten Morgen, {name}.", + "greeting_day": "Hallo, {name}.", + "greeting_evening": "Guten Abend, {name}.", + + "dashboard_resume_label": "Weiter, wo du aufgehört hast", + "dashboard_page_of": "Seite {page} von {pages}", + "dashboard_resume_cta": "Weitertranskribieren", + "dashboard_resume_other": "oder anderen Brief wählen", + "dashboard_empty_title": "Noch kein Dokument begonnen", + "dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.", + "dashboard_empty_cta": "Zum Archiv", + + "dashboard_mission_caption": "Offene Aufgaben", + "queue_segment": "Segmentieren", + "queue_segment_blurb": "Seiten aufteilen", + "queue_transcribe": "Transkribieren", + "queue_transcribe_blurb": "Text erfassen", + "queue_review": "Prüfen", + "queue_review_blurb": "Texte kontrollieren", + "queue_n_open": "{n} offen", + "queue_show_all": "Alle anzeigen →", + + "pulse_eyebrow": "Diese Woche", + "pulse_headline": "Ihr habt {pages} Seiten bearbeitet.", + "pulse_you": "Du selbst hast {pages} davon bearbeitet.", + "pulse_contributors": "Mitwirkende", + "pulse_transcribed": "Textstellen markiert", + "pulse_reviewed": "Textstellen transkribiert", + "pulse_uploaded": "Dokumente hochgeladen", + + "feed_caption": "Kommentare & Aktivität", + "feed_for_you": "für dich", + + "audit_action_text_saved": "hat Text gespeichert in", + "audit_action_file_uploaded": "hat eine Datei hochgeladen:", + "audit_action_annotation_created": "hat eine Markierung erstellt in", + "audit_action_comment_added": "hat kommentiert:", + "audit_action_mention_created": "hat dich erwähnt in", + + "dropzone_release": "Loslassen zum Hochladen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 18fda68a..43e213f3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -706,5 +706,46 @@ "admin_new_invite_expires": "Expiry date (optional)", "admin_invite_created_title": "Invite created", "admin_invite_created_desc": "Share this link with the person you are inviting:", - "admin_invite_revoke_confirm": "Really revoke this invite?" + "admin_invite_revoke_confirm": "Really revoke this invite?", + + "greeting_morning": "Good morning, {name}.", + "greeting_day": "Hello, {name}.", + "greeting_evening": "Good evening, {name}.", + + "dashboard_resume_label": "Continue where you left off", + "dashboard_page_of": "Page {page} of {pages}", + "dashboard_resume_cta": "Continue transcribing", + "dashboard_resume_other": "or choose another document", + "dashboard_empty_title": "No document started yet", + "dashboard_empty_body": "Choose a document from the archive to start transcribing.", + "dashboard_empty_cta": "To the archive", + + "dashboard_mission_caption": "Open tasks", + "queue_segment": "Segment", + "queue_segment_blurb": "Split pages", + "queue_transcribe": "Transcribe", + "queue_transcribe_blurb": "Capture text", + "queue_review": "Review", + "queue_review_blurb": "Check texts", + "queue_n_open": "{n} open", + "queue_show_all": "Show all →", + + "pulse_eyebrow": "This week", + "pulse_headline": "You have worked on {pages} pages.", + "pulse_you": "You personally worked on {pages} of them.", + "pulse_contributors": "Contributors", + "pulse_transcribed": "Passages annotated", + "pulse_reviewed": "Passages transcribed", + "pulse_uploaded": "Documents uploaded", + + "feed_caption": "Comments & activity", + "feed_for_you": "for you", + + "audit_action_text_saved": "saved text in", + "audit_action_file_uploaded": "uploaded a file:", + "audit_action_annotation_created": "created an annotation in", + "audit_action_comment_added": "commented:", + "audit_action_mention_created": "mentioned you in", + + "dropzone_release": "Release to upload" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1e59d1c6..254fae9a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -706,5 +706,46 @@ "admin_new_invite_expires": "Fecha de vencimiento (opcional)", "admin_invite_created_title": "Invitación creada", "admin_invite_created_desc": "Comparte este enlace con la persona invitada:", - "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?" + "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?", + + "greeting_morning": "Buenos días, {name}.", + "greeting_day": "Hola, {name}.", + "greeting_evening": "Buenas noches, {name}.", + + "dashboard_resume_label": "Continuar donde lo dejaste", + "dashboard_page_of": "Página {page} de {pages}", + "dashboard_resume_cta": "Continuar transcripción", + "dashboard_resume_other": "o elige otro documento", + "dashboard_empty_title": "Aún no has comenzado ningún documento", + "dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.", + "dashboard_empty_cta": "Al archivo", + + "dashboard_mission_caption": "Tareas pendientes", + "queue_segment": "Segmentar", + "queue_segment_blurb": "Dividir páginas", + "queue_transcribe": "Transcribir", + "queue_transcribe_blurb": "Capturar texto", + "queue_review": "Revisar", + "queue_review_blurb": "Controlar textos", + "queue_n_open": "{n} pendiente", + "queue_show_all": "Ver todo →", + + "pulse_eyebrow": "Esta semana", + "pulse_headline": "Habéis trabajado {pages} páginas.", + "pulse_you": "Tú mismo has trabajado {pages} de ellas.", + "pulse_contributors": "Colaboradores", + "pulse_transcribed": "Fragmentos anotados", + "pulse_reviewed": "Fragmentos transcritos", + "pulse_uploaded": "Documentos subidos", + + "feed_caption": "Comentarios y actividad", + "feed_for_you": "para ti", + + "audit_action_text_saved": "guardó texto en", + "audit_action_file_uploaded": "subió un archivo:", + "audit_action_annotation_created": "creó una anotación en", + "audit_action_comment_added": "comentó:", + "audit_action_mention_created": "te mencionó en", + + "dropzone_release": "Suelta para subir" } -- 2.49.1 From 10dbce1c70841389514097e35637d6bf1259f58a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:44:08 +0200 Subject: [PATCH 08/49] feat(dashboard): complete frontend redesign for Issue #271 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - +layout.svelte: Upload button in header (authenticated users only) - +page.server.ts: call /api/dashboard/resume, /pulse, /activity; remove deprecated /api/documents/incomplete and /recent-activity - +page.svelte: 2-col grid layout (main + 320px sidebar), greeting, DashboardFamilyPulse + DashboardActivityFeed in sidebar - DashboardResumeStrip: refactored to use server data (resumeDoc prop), SVG thumbnail, progress bar with aria-*, empty state, CTA - DashboardFamilyPulse: new component — weekly stats from audit_log - DashboardActivityFeed: new component — activity feed with "für dich" badge - Update specs for new data shapes Co-Authored-By: Claude Sonnet 4.6 --- .../components/DashboardActivityFeed.svelte | 81 +++++++++++ .../DashboardActivityFeed.svelte.spec.ts | 42 ++++++ .../components/DashboardFamilyPulse.svelte | 70 ++++++++++ .../components/DashboardResumeStrip.svelte | 128 ++++++++++++++---- .../DashboardResumeStrip.svelte.spec.ts | 68 +++++----- frontend/src/routes/+layout.svelte | 28 +++- frontend/src/routes/+page.server.ts | 45 +++--- frontend/src/routes/+page.svelte | 72 +++++----- frontend/src/routes/page.server.spec.ts | 63 ++++++--- frontend/src/routes/page.svelte.spec.ts | 48 ++++--- 10 files changed, 488 insertions(+), 157 deletions(-) create mode 100644 frontend/src/lib/components/DashboardActivityFeed.svelte create mode 100644 frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts create mode 100644 frontend/src/lib/components/DashboardFamilyPulse.svelte diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte b/frontend/src/lib/components/DashboardActivityFeed.svelte new file mode 100644 index 00000000..f11691b0 --- /dev/null +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte @@ -0,0 +1,81 @@ + + +
+
+

+ {m.feed_caption()} +

+ Alle → +
+ + {#if feed.length > 0} +
    + {#each feed as item (item.happenedAt + item.documentId + item.kind)} +
  • + {#if item.actor} + {item.actor.initials} + {:else} + ? + {/if} + +
    +

    + {#if item.actor} + {item.actor.name ?? item.actor.initials} + {/if} + {verb(item.kind)} + + {item.documentTitle} + + {#if item.youMentioned} + + {m.feed_for_you()} + + {/if} +

    +

    {formatDate(item.happenedAt)}

    +
    +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts b/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts new file mode 100644 index 00000000..b12c682c --- /dev/null +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import DashboardActivityFeed from './DashboardActivityFeed.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; + +afterEach(() => { + cleanup(); +}); + +const baseItem: ActivityFeedItemDTO = { + kind: 'TEXT_SAVED', + actor: { initials: 'MR', color: '#7a4f9a', name: 'Max Raddatz' }, + documentId: 'doc-1', + documentTitle: 'Brief 1920', + happenedAt: '2026-04-19T10:00:00Z', + youMentioned: false +}; + +describe('DashboardActivityFeed', () => { + it('renders "für dich" badge when youMentioned is true', async () => { + const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true }; + render(DashboardActivityFeed, { feed: [item] }); + const badge = page.getByText('für dich'); + await expect.element(badge).toBeInTheDocument(); + }); + + it('does not render "für dich" badge when youMentioned is false', async () => { + render(DashboardActivityFeed, { feed: [baseItem] }); + const badge = page.getByText('für dich'); + await expect.element(badge).not.toBeInTheDocument(); + }); + + it('renders empty state when feed is empty', async () => { + render(DashboardActivityFeed, { feed: [] }); + const section = page.getByText('Kommentare & Aktivität'); + await expect.element(section).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/DashboardFamilyPulse.svelte b/frontend/src/lib/components/DashboardFamilyPulse.svelte new file mode 100644 index 00000000..20c50268 --- /dev/null +++ b/frontend/src/lib/components/DashboardFamilyPulse.svelte @@ -0,0 +1,70 @@ + + +{#if pulse !== null} +
+

+ {m.pulse_eyebrow()} +

+ + {#if pulse.pages > 0} +

+ Ihr habt {pulse.pages} Seiten bearbeitet. +

+ {/if} + + {#if pulse.yourPages > 0} +

+ Du selbst hast {pulse.yourPages} davon bearbeitet. +

+ {/if} + + {#if pulse.contributors.length > 0} +
+

{m.pulse_contributors()}

+ {#each pulse.contributors as c (c.initials)} + {c.initials} + {/each} +
+ {/if} + +
+
+ {pulse.annotated} + + {m.pulse_transcribed()} + +
+
+ {pulse.transcribed} + + {m.pulse_reviewed()} + +
+
+ {pulse.uploaded} + + {m.pulse_uploaded()} + +
+
+
+{/if} diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte b/frontend/src/lib/components/DashboardResumeStrip.svelte index 1e7e9e31..ef18cb0d 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte @@ -1,37 +1,115 @@ -{#if lastVisited} +{#if resumeDoc === null} +{:else} +
+ + +
+

+ + {m.dashboard_resume_label()} + · + {m.dashboard_page_of({ page: resumeDoc.page, pages: resumeDoc.pages })} +

+ +

{resumeDoc.title}

+ +

{resumeDoc.caption}

+ +
+ {resumeDoc.excerpt} +
+ +
+ {resumeDoc.pct}% +
+
+
+ {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials)} + {collab.initials} + {/each} +
+ + +
+
{/if} diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts index 2fac46b0..ec241bf5 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts @@ -3,48 +3,48 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import DashboardResumeStrip from './DashboardResumeStrip.svelte'; +import type { components } from '$lib/generated/api'; + +type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; afterEach(() => { cleanup(); - localStorage.clear(); }); +const mockResume: DashboardResumeDTO = { + documentId: 'doc-123', + title: 'Geburtsurkunde 1920', + caption: 'Max Mustermann · 1920-01-01', + excerpt: 'Hiermit wird beurkundet…', + page: 1, + pages: 4, + pct: 75, + collaborators: [] +}; + describe('DashboardResumeStrip', () => { - it('renders nothing when no last-visited document in localStorage', async () => { - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).not.toBeInTheDocument(); + it('renders empty state heading when resumeDoc is null', async () => { + render(DashboardResumeStrip, { resumeDoc: null }); + const heading = page.getByRole('heading', { name: /Noch kein Dokument begonnen/i }); + await expect.element(heading).toBeInTheDocument(); }); - it('shows the strip with link when localStorage has a document', async () => { - localStorage.setItem( - 'familienarchiv.lastVisited', - JSON.stringify({ id: 'doc-123', title: 'Geburtsurkunde 1920' }) - ); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).toBeInTheDocument(); - const link = page.getByRole('link', { name: /Geburtsurkunde 1920/ }); - await expect.element(link).toBeInTheDocument(); + it('renders progressbar with correct aria-valuenow when resumeDoc is provided', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const bar = page.getByRole('progressbar'); + await expect.element(bar).toBeInTheDocument(); + await expect.element(bar).toHaveAttribute('aria-valuenow', '75'); + }); + + it('shows document title when resumeDoc is provided', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const title = page.getByRole('heading', { name: /Geburtsurkunde 1920/i }); + await expect.element(title).toBeInTheDocument(); + }); + + it('links to the document for the CTA', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const link = page.getByRole('link', { name: /Weitertranskribieren/i }); await expect.element(link).toHaveAttribute('href', '/documents/doc-123'); }); - - it('uses title fallback text when title is empty', async () => { - localStorage.setItem( - 'familienarchiv.lastVisited', - JSON.stringify({ id: 'doc-456', title: '' }) - ); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).toBeInTheDocument(); - const link = page.getByRole('link'); - await expect.element(link).toHaveAttribute('href', '/documents/doc-456'); - }); - - it('renders nothing when localStorage contains malformed JSON', async () => { - localStorage.setItem('familienarchiv.lastVisited', '{not valid json'); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).not.toBeInTheDocument(); - }); }); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3aada446..0469053e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -28,7 +28,9 @@ onMount(() => { }); const isAuthPage = $derived( - ['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p)) + ['/login', '/register', '/forgot-password', '/reset-password'].some((p) => + page.url.pathname.startsWith(p) + ) ); const userInitials = $derived.by(() => { @@ -50,6 +52,30 @@ const userInitials = $derived.by(() => {
+ {#if data?.user} + + + Hochladen + + {/if} - {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials)} + {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)} {collab.initials} { const link = page.getByRole('link', { name: /Weitertranskribieren/i }); await expect.element(link).toHaveAttribute('href', '/documents/doc-123'); }); + + it('shows block count label', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const label = page.getByText(/4 Abschnitte/i); + await expect.element(label).toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 20918c9a..c16e40c3 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1982,9 +1982,7 @@ export interface components { caption: string; excerpt: string; /** Format: int32 */ - page: number; - /** Format: int32 */ - pages: number; + totalBlocks: number; /** Format: int32 */ pct: number; thumbnailUrl?: string; -- 2.49.1 From 148710f2ed1b010f95ada87da78ceb36e7d587f3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 21:39:56 +0200 Subject: [PATCH 24/49] docs(spec): add /documents page design spec with mobile breakpoints Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/documents-page-spec.html | 665 ++++++++++++++++++ .../components/DashboardActivityFeed.svelte | 8 +- 2 files changed, 670 insertions(+), 3 deletions(-) create mode 100644 docs/specs/documents-page-spec.html diff --git a/docs/specs/documents-page-spec.html b/docs/specs/documents-page-spec.html new file mode 100644 index 00000000..f69e2ef1 --- /dev/null +++ b/docs/specs/documents-page-spec.html @@ -0,0 +1,665 @@ + + + + + +Dokumente-Seite — Design Spec + + + +
+ + +
+
+ Neue Route + Frontend + Backend +
+

Dokumente-Seite — /documents

+

+ Dedicated search and browse page for all documents. Separates the document list from the dashboard hub. Uses per-year group cards with flat divide-y rows, a horizontal split row (content left · metadata right), a circular progress ring, and contributor avatars. +

+
Spec · Leonie Voss · 2026-04-19 · Issue TBD
+
+ +
+

Design decisions

+

The hub (/) becomes pure dashboard — no more dual-mode switching. The "Documents" nav tab points to /documents, a focused search/browse page.

+

Row layout: two-column split — title and snippet occupy the full left column for maximum scan width; date, sender, receiver, archive location, progress ring and contributor avatars live in a fixed 240px right panel. This keeps metadata consistently positioned across all rows.

+

List structure: one white card container per year group (matching the current border border-line bg-surface shadow-sm pattern), rows separated by divide-y dividers — no gaps, no individual row cards. The year label is an inset header row within each card.

+

Progress ring shows work completion as a percentage (0–100%). It is driven by a new completionPercentage field on the search result DTO, computed server-side from annotation block counts. Contributor avatars require a new contributors array (initials + color) on the search DTO.

+
+ +
+ + +
+
Section 1
+
Full page mockup — filter panel open, search active
+
Scaled at ~56%. Desktop 1200px concept width.
+ +
+ +
+ + +
Hochlader
MR
+
+ + + +
+ 31 documents + Sort +
Date ↓
+
+ + Filters 1 +
+
+ +
+
+ Date range +
From
To
+
+
+ Sender +
Search person…
+
+
+ Receiver +
Search person…
+
+
+ Tags + BriefFotoPostkarteUrkunde +
+
+ +
+ + +
+
1924
+
+
+
Demo: Ierlicher Brief — Belgern
+
… Hiermit übersende ich Ihnen den gewünschten Brief meines Vaters, welcher einige interessante Hinweise zur Familiengeschichte enthält …
+
Brief
Familie
+
+
+
+
Date 31. Mai 1924
+
From Louise Aon Boden
+
To Marcel Raddatz
+
Archive Box 3 · Folder A
+
+
+
+ +
100%
+
+
MR
LS
+
+
+
+
+ + +
+
1923
+
+
+
W-0614 – 8. September 1923 – Tölz
+
… Clara schreibt über die Ankunft in Tölz und erwähnt den letzten Brief von Fauld Rupley, der noch keine Antwort erhalten hat …
+
Brief
+
+
+
+
Date 8. Sept. 1923
+
From Clara Lam
+
To Fauld Rupley
+
Archive Box 1 · Folder C
+
+
+
+ +
75%
+
+
AK
+
+
+
+
+
+
W-0196 – 2. September 1923 – B. Lichterfelde
+
… Prediger's Haushaltung enthält einen Brief; Zusammen mit der Vollmacht aus dem Vorjahr ergibt sich folgendes Bild …
+
Brief
+
+
+
+
Date 2. Sept. 1923
+
From Müller de Gruym
+
To Herbert Cram
+
+
+
+ +
40%
+
+
MR
LS
AK
+
+
+
+
+
+
W-0397 – 2. September 1923 – B. Lichterfelde
+
… zum einleitend Kommentar hieraus, den Herrn, zum Brief az sechzig und weitere Passagen …
+
Brief
+
+
+
+
Date 2. Sept. 1923
+
From Müller de Gruym
+
+
+
+ +
0%
+
+ No contributors +
+
+
+
+ +
+
+ Fig 1 — /documents · 1200px · search: "brief" · filter panel open · sort: Date ↓ +
+ +
+ + +
+
Section 2
+
Page structure & zones
+ +
+
+

① Global search bar

+

Full-width row below the topbar. Contains the search input (flex-1), result count (right of input), and "+ New Document" button. Background white, bottom border border-line. Sticky — stays visible on scroll.

+

Same search bar pattern as the current homepage. Debounce 500 ms on text input; immediate on clear.

+
+
+

② Sort / count bar

+

Slim bar below search. Shows result count (left), sort dropdown (right), and Filters toggle button (far right). Background white, bottom border border-line. Sticky — stacks below search bar on scroll.

+

Filters button shows a mint badge with active filter count. When filters are open the button fills navy.

+
+
+

③ Collapsible filter panel

+

Drops open below the sort bar. Contains four groups: Date range (two inputs), Sender (PersonTypeahead), Receiver (PersonTypeahead), Tags (clickable pills). White background, bottom border border-line.

+

Closed by default on page load unless URL already has active filter params. Animate open/close with transition-all duration-200.

+
+
+ + +
+ +
+ + +
+
Section 2b
+
Mobile breakpoints
+
Three responsive tiers: <sm (mobile), sm–lg (tablet), lg+ (desktop).
+ +
+
+

< sm — < 640px (mobile)

+

Document row: single-column block. Left/right split collapses. Metadata (date, from, to, archive) moves below the tags row as a 2×2 compact grid. Progress ring and contributor stack appear in a bottom row directly below the grid.

+

Filter panel: single-column stack (flex-col). Sort bar wraps if needed.

+
+
+

sm – lg — 640–1023px

+

Document row: two-column split restored. Metadata column narrower: sm:w-48 (192px) instead of w-60 to fit tablet viewports.

+

Sticky bars span full width via negative margins. Filter panel: flex-row flex-wrap, groups can wrap.

+
+
+

lg+ — ≥ 1024px (desktop)

+

Full two-column split. Metadata column: lg:w-60 (240px). Filter panel: four groups in a single row. Max content width max-w-7xl (1280px) — from app layout container, no extra padding on list body.

+
+
+ + +
+
+
+
+ +
+ Dokumente +
MR
+
+
+ +
+ 31 documents + Sort +
Date ↓
+
Filters
+
+
+
+
1924
+ +
+
Demo: Ierlicher Brief — Belgern
+
… Hiermit übersende ich Ihnen den gewünschten Brief
+
Brief
+
+
Date 31. Mai 1924
+
From L. von Boden
+
Archive Box 3 · A
+
To M. Raddatz
+
+
+
+ +
100%
+
+
MR
LS
+
+
+ +
+
W-0614 – Sept. 1923 – Tölz
+
… Clara schreibt über den letzten Brief von Fauld Rupley …
+
Brief
+
+
Date 8. Sept. 1923
+
From Clara Lam
+
+
To F. Rupley
+
+
+
+ +
75%
+
+
AK
+
+
+
+
+
+ Fig 2 — /documents · 375px mobile · search "brief" · filter closed +
+ +
+

Mobile row — CSS-only approach

+

No JS needed. The <a> link is always block. On sm+ the inner element switches to flex items-stretch, showing the right metadata column (hidden sm:flex) and hiding the mobile compact grid (sm:hidden).

+

This means the DOM contains both layouts simultaneously — the metadata grid inside the left column (mobile only) and the right metadata panel (sm+ only). Both share the same data, just rendered differently.

+

Minimum touch target: the entire row is the <a>, guaranteed ≥44px on mobile given title + snippet + tags + metadata grid.

+
+
+
+ +
+ + +
+
Section 3
+
Year group card
+
One card per year group. Rows inside use divide-y — no gaps between rows.
+ +
+
+

Card container

+

Matches current DocumentList outer container exactly: border border-line bg-surface shadow-sm. No border-radius (keeps it flush). Margin between consecutive year cards: mb-4.

+

Rendered only when sort = DATE. For other sort modes (SENDER, RECEIVER, TITLE) the year header is replaced by the relevant group label using the same card pattern.

+
+
+

Year header row

+

First child of each card. Background bg-sand, text text-xs font-bold uppercase tracking-widest text-ink-3. Height py-1.5 px-5. Bottom border border-b border-line.

+

Not a standalone divider — it is part of the card so the top border of the card frames the year label on three sides.

+
+
+
+ +
+ + +
+
Section 4
+
Document row — two-column split
+ +
+
+

Left column — content

+

Flex-1, min-width 0. Padding p-4 pr-5. Right border border-r border-line-2.

+

Titlefont-serif text-base font-bold text-ink with search highlight underlines. mb-1.5.

+

Snippetfont-serif text-sm italic text-ink-2 line-clamp-2 mb-2 with highlight underlines. Only rendered when a match snippet is present.

+

Tags — existing tag pill pattern bg-muted text-ink text-[10px] font-bold uppercase tracking-widest rounded px-2 py-0.5. Gap gap-1.5 flex-wrap.

+
+
+

Right column — metadata panel

+

Fixed width w-60 (240px). Padding p-3.5. Flex column, justify-between.

+

Meta lines (top group) — font-sans text-[11px] text-ink-2 mb-1. Label: font-bold uppercase tracking-wide text-[10px] text-ink-3 mr-1.5. Lines: Date · From · To · Archive (Box · Folder). Archive only rendered when archiveBox is set.

+

Bottom row — flexbox, space-between. Left: progress ring. Right: ContributorStack.

+
+
+ +
+
Accessibility: The ring conveys progress by both percentage text and arc fill — not colour alone. Contributors show initials as text inside the avatar. Both pass the redundant-cue requirement from the Leonie Voss persona. Minimum touch target for the row link: the full row is the <a> element, always ≥44px tall given the content. Row hover: hover:bg-muted/50 transition-colors duration-200.
+
+
+ +
+ + +
+
Section 5
+
Progress ring
+ +
+
+

Anatomy

+

SVG donut ring, 36×36px. Track circle: stroke="#E4E2D7" (stroke-brand-sand) width 3px. Fill arc: stroke="#A6DAD8" (stroke-accent) width 3px, stroke-linecap="round". Rotated −90° so arc starts at 12 o'clock.

+

Centre label: percentage text font-sans text-[8px] font-bold. Colour: mint (text-accent-dark) when >0%, gray-400 when 0%.

+

Circumference of r=13: 2π×13 ≈ 81.7px. Stroke-dasharray: {pct * 81.7} 81.7.

+
+
+

Data source — new API field

+

New field completionPercentage: number (0–100, integer) on the document search result DTO. Computed server-side:

+

round((reviewedBlocks / max(totalBlocks, 1)) * 100)

+

If a document has no annotation blocks yet (no transcription started), returns 0. Backend change: new subquery in the document search repository to COUNT annotation blocks (all vs. reviewed) per document, joined into the search projection.

+
+
+
+ +
+ + +
+
Section 6
+
Contributor avatar stack
+ +
+
+

Anatomy

+

Reuse existing ContributorStack.svelte component (added in commit 031f6ea). Avatars 22×22px, -ml-1.5 overlap, white 2px border.

+

Show max 3 avatars. If more: +N text element in gray-400. When no contributors: render text-[9px] text-ink-3 uppercase tracking-wide label "No contributors".

+
+
+

Data source — new API field

+

New field contributors: ActivityActorDTO[] on the document search result DTO. ActivityActorDTO already exists (used in dashboard queue items): { initials: string, color: string, name?: string }.

+

Backend: join from document → annotation_blocks → created_by → users. Distinct by user. Order by most-recent contribution. Limit 4. New query in document search repository.

+
+
+
+ +
+ + +
+
Section 7
+
Backend changes required
+ +
+
New fields on document search DTO — Two new fields must be added to the object returned by GET /api/documents/search. These require a new projection or join in the repository layer. No schema migration needed — purely computed from existing annotation_block data.
+
+ +
+ + + + + + + + +
FieldTypeSourceNotes
completionPercentageint (0–100)COUNT(reviewed annotation blocks) / COUNT(all blocks)0 when no blocks exist
contributorsActivityActorDTO[]Distinct users with annotation_block contributions, ordered by recencyMax 4; reuse existing DTO
archiveBoxString?Already on Document entity — just not in search responseExpose existing field
archiveFolderString?Already on Document entity — just not in search responseExpose existing field
+
+
+ +
+ + +
+
Section 8 — Implementation Reference
+
Exact Tailwind classes & pixel values
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesPixels / valueNotes
Page chrome
Search bar wrapperbg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3.5 flex items-center gap-3 sticky top-[65px] z-20padding 14px responsiveTopbar = 1px accent + 64px nav = 65px. Negative margins break out of container padding so bar spans full container width.
Search inputflex-1 h-9 border border-ink rounded-sm px-3 font-sans text-sm text-ink bg-whiteheight 36pxActive: navy border
Sort bar wrapperbg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-2.5 flex items-center gap-3 sticky top-[113px] z-20padding 10px responsiveStacks below search bar (65 + 48 = 113px)
Filters toggle (closed)h-7 px-3 border border-line rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide text-ink flex items-center gap-1.5height 28px
Filters toggle (open)h-7 px-3 bg-ink text-white rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide flex items-center gap-1.5height 28pxNavy fill when active
Filter panel wrapperbg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-4 flex flex-col sm:flex-row sm:flex-wrap gap-4padding 16px responsiveUse Svelte slide transition; stacks vertically on mobile
List bodypy-5vertical padding onlyNo extra horizontal padding — app container handles it
Year group card
Card containerborder border-line bg-surface shadow-sm mb-4 overflow-hiddenMatches current DocumentList outer div exactly
Year headerbg-sand border-b border-line px-5 py-1.5 font-sans text-[10px] font-bold uppercase tracking-widest text-ink-3padding 6px 20px
Row listdivide-y divide-line-2Matches current <ul> pattern
Document row
Row wrapper <li>group transition-colors duration-200 hover:bg-muted/50Same hover pattern as current
Row inner (link)block sm:flex sm:items-stretchFull-row <a href="/documents/{id}">; flex only on sm+
Left columnp-4 sm:flex-1 sm:min-w-0 sm:pr-5 sm:border-r sm:border-line-2padding 16pxRight border only on sm+
Right column (sm+)hidden sm:flex sm:w-48 lg:w-60 flex-shrink-0 p-3.5 flex-col justify-between gap-2sm: 192px · lg: 240pxHidden on mobile; narrower on tablet
Mobile metadata gridsm:hidden border-t border-line-2 mt-3 pt-3 grid grid-cols-2 gap-x-4 gap-y-0.52×2 compact grid shown only on mobile, inside left col
Mobile meta bottom rowsm:hidden flex items-center justify-between mt-3Ring + contributors on mobile, shown only <sm
Document titlefont-serif text-base font-bold text-ink mb-1.5 leading-snug group-hover:underline16px / 700
Snippet textfont-serif text-sm italic text-ink-2 line-clamp-2 mb-214pxOnly when snippet present
Meta labelfont-sans text-[10px] font-bold uppercase tracking-wide text-ink-3 mr-1.510px / 700DATE · FROM · TO · ARCHIVE
Meta valuefont-sans text-[11px] text-ink-211px
Progress ring
SVG containerrelative w-9 h-9 flex-shrink-036×36px
Track circlestroke="var(--c-sand)" stroke-width="3"r=13, circumference 81.7px
Fill arcstroke="var(--c-accent)" stroke-width="3" stroke-linecap="round"dasharray = pct/100 × 81.7rotate(−90deg)
Percentage labelabsolute inset-0 flex items-center justify-center font-sans text-[8px] font-bold8px / 800Mint when >0, gray-400 when 0
New files
NEW frontend/src/routes/documents/+page.svelteDocument list page (extract from homepage)
NEW frontend/src/routes/documents/+page.server.tsLoads search results, same API call as current homepage
CHANGED frontend/src/routes/AppNav.svelteDocuments tab href: //documents
CHANGED frontend/src/routes/+page.svelteRemove dual-mode logic; always render dashboard
CHANGED frontend/src/routes/+page.server.tsRemove search branch; always fetch dashboard data
CHANGED frontend/src/routes/DocumentList.svelteRefactor to new two-column layout + year cards
NEW query backend/.../DocumentSearchRepositoryAdd completionPercentage + contributors to search projection
+
+
+ +
+ + diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte b/frontend/src/lib/components/DashboardActivityFeed.svelte index f11691b0..d24fdd91 100644 --- a/frontend/src/lib/components/DashboardActivityFeed.svelte +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte @@ -34,8 +34,10 @@ function formatDate(iso: string): string {

{m.feed_caption()}

- Alle →{m.feed_show_all()}
@@ -61,7 +63,7 @@ function formatDate(iso: string): string { {item.actor.name ?? item.actor.initials} {/if} {verb(item.kind)} - + {item.documentTitle} {#if item.youMentioned} -- 2.49.1 From 2bb08b68771bae6b4fc460b471e19187b9f7f59c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 21:43:16 +0200 Subject: [PATCH 25/49] fix(dashboard): i18n, a11y, security, and type-safety fixes from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use @RequiredArgsConstructor in AuditLogQueryService; remove unused import - Add 401/403 tests for /activity endpoint - Add getPulseStats and findContributorsPerDocument integration tests - Use m.pulse_headline/pulse_you in FamilyPulse; composite avatar keys - Replace hover:text-accent with hover:text-ink in ActivityFeed (WCAG AA) - Localise "Alle →" link with feed_show_all key + aria-label - Gate DropZone behind {#if data.canWrite} - Export DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO from api.ts Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/AuditLogQueryService.java | 7 +-- ...uditLogQueryRepositoryIntegrationTest.java | 52 +++++++++++++++++++ .../dashboard/DashboardControllerTest.java | 13 +++++ .../components/DashboardFamilyPulse.svelte | 6 +-- frontend/src/lib/generated/api.ts | 4 ++ frontend/src/routes/+page.svelte | 4 +- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java index a7a45104..035bc2e4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java @@ -1,20 +1,17 @@ package org.raddatz.familienarchiv.dashboard; -import org.raddatz.familienarchiv.audit.AuditLogRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.OffsetDateTime; import java.util.*; @Service +@RequiredArgsConstructor public class AuditLogQueryService { private final AuditLogQueryRepository queryRepository; - public AuditLogQueryService(AuditLogQueryRepository queryRepository) { - this.queryRepository = queryRepository; - } - public Optional findMostRecentDocumentForUser(UUID userId) { return queryRepository.findMostRecentDocumentIdByActor(userId); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java index 0e0f7e6a..6121a856 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java @@ -9,6 +9,9 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas import org.springframework.context.annotation.Import; import org.springframework.test.context.jdbc.Sql; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -35,4 +38,53 @@ class AuditLogQueryRepositoryIntegrationTest { assertThat(result).contains(DOC_ID); } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')" + }) + void findDedupedActivityFeed_returnsAnnotationEntry() { + List rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED"); + assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID); + assertThat(rows.get(0).getHappenedAt()).isNotNull(); + } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')", + "INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"blockId\":\"ccc\",\"pageNumber\":1}')", + "INSERT INTO audit_log (kind, document_id) VALUES ('FILE_UPLOADED', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void getPulseStats_countsAnnotationsTranscriptionsAndUploads() { + OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC).minusDays(7); + + PulseStatsRow stats = auditLogQueryRepository.getPulseStats(weekStart, USER_ID.toString()); + + assertThat(stats.getAnnotated()).isEqualTo(1); + assertThat(stats.getTranscribed()).isEqualTo(1); + assertThat(stats.getUploaded()).isEqualTo(1); + assertThat(stats.getYourPages()).isGreaterThanOrEqualTo(1); + } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw', 'Anna', 'Meier', '#f00')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void findContributorsPerDocument_returnsContributorWithInitialsAndColor() { + List rows = auditLogQueryRepository.findContributorsPerDocument(List.of(DOC_ID)); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID); + assertThat(rows.get(0).getActorInitials()).isEqualTo("AM"); + assertThat(rows.get(0).getActorColor()).isEqualTo("#f00"); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java index 2c76d683..0f1e4922 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java @@ -113,6 +113,19 @@ class DashboardControllerTest { .andExpect(jsonPath("$.annotated").value(23)); } + @Test + void activity_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/dashboard/activity")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void activity_returns403_whenUserHasNoPermissions() throws Exception { + mockMvc.perform(get("/api/dashboard/activity")) + .andExpect(status().isForbidden()); + } + // ─── GET /api/dashboard/activity ───────────────────────────────────────── @Test diff --git a/frontend/src/lib/components/DashboardFamilyPulse.svelte b/frontend/src/lib/components/DashboardFamilyPulse.svelte index 20c50268..44f89542 100644 --- a/frontend/src/lib/components/DashboardFamilyPulse.svelte +++ b/frontend/src/lib/components/DashboardFamilyPulse.svelte @@ -17,20 +17,20 @@ const { pulse }: Props = $props(); {#if pulse.pages > 0}

- Ihr habt {pulse.pages} Seiten bearbeitet. + {m.pulse_headline({ pages: pulse.pages })}

{/if} {#if pulse.yourPages > 0}

- Du selbst hast {pulse.yourPages} davon bearbeitet. + {m.pulse_you({ pages: pulse.yourPages })}

{/if} {#if pulse.contributors.length > 0}

{m.pulse_contributors()}

- {#each pulse.contributors as c (c.initials)} + {#each pulse.contributors as c (c.initials + c.color)} {
- + {#if data.canWrite} + + {/if}
{:else} -- 2.49.1 From 94823f85c88374274fe52afd99731d32ad68516f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:19:50 +0200 Subject: [PATCH 26/49] =?UTF-8?q?test(dashboard):=20fix=20stale=20resume?= =?UTF-8?q?=20mock=20=E2=80=94=20use=20totalBlocks=20instead=20of=20page/p?= =?UTF-8?q?ages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/page.server.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 0baa0cb9..a4b47e06 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -37,8 +37,7 @@ describe('home page load — dashboard mode', () => { title: 'T', caption: '', excerpt: '', - page: 1, - pages: 2, + totalBlocks: 2, pct: 50, collaborators: [] } @@ -71,6 +70,7 @@ describe('home page load — dashboard mode', () => { expect(result.isDashboard).toBe(true); expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 }); expect(result.resumeDoc).not.toBeNull(); + expect(result.resumeDoc?.totalBlocks).toBe(2); expect(result.pulse).not.toBeNull(); expect(result.activityFeed).toEqual([]); expect(result.documents).toEqual([]); -- 2.49.1 From d6e5d3d1e8b7ab4673bd55bfb37f1aeb75d97570 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:23:32 +0200 Subject: [PATCH 27/49] fix(layout): replace hardcoded 'Hochladen' with m.upload_action() + aria-label Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/+layout.svelte | 4 +++- frontend/src/routes/layout.svelte.spec.ts | 16 ++++++++++++++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7e491bec..31a922f5 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -363,6 +363,7 @@ "doc_panel_discussion_annotation_tab": "Annotation · Seite {page}", "pdf_annotations_show": "Annotierungen anzeigen", "pdf_annotations_hide": "Annotierungen verbergen", + "upload_action": "Hochladen", "upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8e0ae64a..c594b6e0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -363,6 +363,7 @@ "doc_panel_discussion_annotation_tab": "Annotation · Page {page}", "pdf_annotations_show": "Show annotations", "pdf_annotations_hide": "Hide annotations", + "upload_action": "Upload", "upload_drop_hint": "Drop one or multiple files at once", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a481bf85..3195c2a9 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -363,6 +363,7 @@ "doc_panel_discussion_annotation_tab": "Anotación · Página {page}", "pdf_annotations_show": "Mostrar anotaciones", "pdf_annotations_hide": "Ocultar anotaciones", + "upload_action": "Subir", "upload_drop_hint": "Uno o varios archivos a la vez", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados", diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0469053e..33671854 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import './layout.css'; import { page } from '$app/state'; import { onMount } from 'svelte'; +import * as m from '$lib/paraglide/messages.js'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import NotificationBell from '$lib/components/NotificationBell.svelte'; @@ -55,6 +56,7 @@ const userInitials = $derived.by(() => { {#if data?.user} { - Hochladen + {m.upload_action()} {/if} diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts index 3de2e9a8..7600053a 100644 --- a/frontend/src/routes/layout.svelte.spec.ts +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -48,6 +48,22 @@ describe('Layout – user avatar button', () => { }); }); +// ─── Upload link ────────────────────────────────────────────────────────────── + +describe('Layout – upload link', () => { + it('has aria-label for screen reader access', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i }); + await expect.element(link).toHaveAttribute('aria-label'); + }); + + it('navigates to /documents/new', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i }); + await expect.element(link).toHaveAttribute('href', '/documents/new'); + }); +}); + // ─── Dropdown ───────────────────────────────────────────────────────────────── describe('Layout – user dropdown', () => { -- 2.49.1 From 52466380144f1426f4882f5894e0e370474aa60e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:28:00 +0200 Subject: [PATCH 28/49] fix(dashboard): fix ContributorStack each-block key and add accessible avatar labels - Replace (actor.name ?? actor.initials + i) with (actor.initials + '-' + actor.color) to fix operator-precedence bug that made keys order-dependent when name is null - Add role="img" + aria-label={actor.name ?? actor.initials} so screen readers and touch users can access contributor names Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/ContributorStack.svelte | 6 ++- .../ContributorStack.svelte.spec.ts | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/ContributorStack.svelte.spec.ts diff --git a/frontend/src/lib/components/ContributorStack.svelte b/frontend/src/lib/components/ContributorStack.svelte index 8e453284..fc627627 100644 --- a/frontend/src/lib/components/ContributorStack.svelte +++ b/frontend/src/lib/components/ContributorStack.svelte @@ -20,11 +20,13 @@ const safeContributors = $derived(contributors ?? []); > {:else} - {#each safeContributors as actor, i (actor.name ?? actor.initials + i)} + {#each safeContributors as actor, i (actor.initials + '-' + actor.color)} {actor.initials} diff --git a/frontend/src/lib/components/ContributorStack.svelte.spec.ts b/frontend/src/lib/components/ContributorStack.svelte.spec.ts new file mode 100644 index 00000000..877167cd --- /dev/null +++ b/frontend/src/lib/components/ContributorStack.svelte.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ContributorStack from './ContributorStack.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityActorDTO = components['schemas']['ActivityActorDTO']; + +afterEach(() => cleanup()); + +const makeActor = (overrides: Partial = {}): ActivityActorDTO => ({ + initials: 'MR', + color: '#7a4f9a', + name: 'Max Raddatz', + ...overrides +}); + +describe('ContributorStack', () => { + it('contributor avatar is announced by screen readers with actor name', async () => { + const actor = makeActor({ name: 'Anna Meier', initials: 'AM' }); + render(ContributorStack, { contributors: [actor], hasMore: false }); + await expect.element(page.getByRole('img', { name: 'Anna Meier' })).toBeInTheDocument(); + }); + + it('falls back to initials as accessible name when actor name is null', async () => { + const actor = makeActor({ name: undefined, initials: 'AM' }); + render(ContributorStack, { contributors: [actor], hasMore: false }); + await expect.element(page.getByRole('img', { name: 'AM' })).toBeInTheDocument(); + }); + + it('renders two avatars without crashing when actors have identical initials', async () => { + const actors = [ + makeActor({ name: undefined, initials: 'AM', color: '#aa0000' }), + makeActor({ name: undefined, initials: 'AM', color: '#0000bb' }) + ]; + render(ContributorStack, { contributors: actors, hasMore: false }); + await expect.element(page.getByText('AM').first()).toBeInTheDocument(); + }); + + it('renders overflow indicator when hasMore is true', async () => { + render(ContributorStack, { contributors: [makeActor()], hasMore: true }); + await expect.element(page.getByText('…')).toBeInTheDocument(); + }); + + it('renders empty placeholder when no contributors', async () => { + render(ContributorStack, { contributors: [], hasMore: false }); + await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument(); + }); +}); -- 2.49.1 From 70a2bbfaad5eca1c26f46a1ea3b97c8de2fd8cf0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:43:30 +0200 Subject: [PATCH 29/49] refactor(audit): move AuditLogQueryService, AuditLogQueryRepository, and shared DTOs to audit package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TranscriptionQueueService was importing ActivityActorDTO and AuditLogQueryService from the dashboard package, creating an inverted dependency (service → dashboard). Moving these to the audit package where AuditLog lives gives both DashboardService and TranscriptionQueueService the correct dependency direction (→ audit). Moved to audit: - ActivityActorDTO, ActivityFeedRow, ContributorRow, PulseStatsRow (projections) - AuditLogQueryRepository, AuditLogQueryService Co-Authored-By: Claude Sonnet 4.6 --- .../audit/ActivityActorDTO.java | 10 +++++ .../familienarchiv/audit/ActivityFeedRow.java | 15 +++++++ .../AuditLogQueryRepository.java | 5 +-- .../AuditLogQueryService.java | 2 +- .../{dashboard => audit}/ContributorRow.java | 2 +- .../familienarchiv/audit/PulseStatsRow.java | 9 ++++ .../dashboard/ActivityFeedItemDTO.java | 18 ++++++++ .../dashboard/DashboardController.java | 42 +++++++++++++++++++ .../dashboard/DashboardPulseDTO.java | 15 +++++++ .../dashboard/DashboardResumeDTO.java | 1 + .../dashboard/DashboardService.java | 4 ++ .../dto/TranscriptionQueueItemDTO.java | 2 +- .../service/TranscriptionQueueService.java | 4 +- .../TranscriptionQueueControllerTest.java | 2 +- ...uditLogQueryRepositoryIntegrationTest.java | 4 ++ .../dashboard/DashboardServiceTest.java | 2 + .../TranscriptionQueueServiceTest.java | 4 +- 17 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java rename backend/src/main/java/org/raddatz/familienarchiv/{dashboard => audit}/AuditLogQueryRepository.java (97%) rename backend/src/main/java/org/raddatz/familienarchiv/{dashboard => audit}/AuditLogQueryService.java (97%) rename backend/src/main/java/org/raddatz/familienarchiv/{dashboard => audit}/ContributorRow.java (78%) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java new file mode 100644 index 00000000..6bc095e5 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java @@ -0,0 +1,10 @@ +package org.raddatz.familienarchiv.audit; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; + +public record ActivityActorDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String initials, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String color, + @Nullable String name +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java new file mode 100644 index 00000000..384b311e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.audit; + +import java.time.Instant; +import java.util.UUID; + +public interface ActivityFeedRow { + String getKind(); + UUID getActorId(); + String getActorInitials(); + String getActorColor(); + String getActorName(); + UUID getDocumentId(); + Instant getHappenedAt(); + boolean isYouMentioned(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java similarity index 97% rename from backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java rename to backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java index 3a2d186c..adc933d0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -1,6 +1,5 @@ -package org.raddatz.familienarchiv.dashboard; +package org.raddatz.familienarchiv.audit; -import org.raddatz.familienarchiv.audit.AuditLog; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -38,7 +37,7 @@ public interface AuditLogQueryRepository extends JpaRepository { COALESCE(u.color, '') AS actorColor, CONCAT_WS(' ', u.first_name, u.last_name) AS actorName, a.document_id AS documentId, - a.happened_at AS happenedAt, + a.happened_at AS happened_at, (a.kind = 'MENTION_CREATED' AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned FROM audit_log a diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java similarity index 97% rename from backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java rename to backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java index 035bc2e4..da887b05 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -1,4 +1,4 @@ -package org.raddatz.familienarchiv.dashboard; +package org.raddatz.familienarchiv.audit; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ContributorRow.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/ContributorRow.java similarity index 78% rename from backend/src/main/java/org/raddatz/familienarchiv/dashboard/ContributorRow.java rename to backend/src/main/java/org/raddatz/familienarchiv/audit/ContributorRow.java index a60a5ca4..6ee5058e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ContributorRow.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ContributorRow.java @@ -1,4 +1,4 @@ -package org.raddatz.familienarchiv.dashboard; +package org.raddatz.familienarchiv.audit; import java.util.UUID; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java new file mode 100644 index 00000000..e374cccc --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.audit; + +public interface PulseStatsRow { + long getPages(); + long getAnnotated(); + long getTranscribed(); + long getUploaded(); + long getYourPages(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java new file mode 100644 index 00000000..0fcdd312 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dashboard; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.AuditKind; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public record ActivityFeedItemDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) AuditKind kind, + @Nullable ActivityActorDTO actor, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java new file mode 100644 index 00000000..1869c2f4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java @@ -0,0 +1,42 @@ +package org.raddatz.familienarchiv.dashboard; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.security.SecurityUtils; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/dashboard") +@RequirePermission(Permission.READ_ALL) +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + private final UserService userService; + + @GetMapping("/resume") + public DashboardResumeDTO getResume(Authentication authentication) { + UUID userId = SecurityUtils.requireUserId(authentication, userService); + return dashboardService.getResume(userId); + } + + @GetMapping("/pulse") + public DashboardPulseDTO getPulse(Authentication authentication) { + UUID userId = SecurityUtils.requireUserId(authentication, userService); + return dashboardService.getPulse(userId); + } + + @GetMapping("/activity") + public List getActivity( + Authentication authentication, + @RequestParam(defaultValue = "7") int limit) { + UUID userId = SecurityUtils.requireUserId(authentication, userService); + return dashboardService.getActivity(userId, Math.min(limit, 20)); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java new file mode 100644 index 00000000..59d9e931 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.dashboard; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; + +import java.util.List; + +public record DashboardPulseDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pages, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotated, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int transcribed, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int uploaded, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int yourPages, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List contributors +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java index f7fa95ed..44c04b9a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.dashboard; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import java.util.List; import java.util.UUID; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java index 8734c14a..d749a164 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -2,6 +2,10 @@ package org.raddatz.familienarchiv.dashboard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; +import org.raddatz.familienarchiv.audit.PulseStatsRow; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java index 5f3d3de5..36d63ca7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java @@ -1,7 +1,7 @@ package org.raddatz.familienarchiv.dto; import io.swagger.v3.oas.annotations.media.Schema; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import java.time.LocalDate; import java.util.List; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java index 93f93926..6b82abb9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java @@ -1,8 +1,8 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; -import org.raddatz.familienarchiv.dashboard.AuditLogQueryService; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; import org.raddatz.familienarchiv.repository.DocumentRepository; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java index de325050..183d024f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java @@ -17,7 +17,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import java.time.LocalDate; import java.util.List; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java index 6121a856..5875baf5 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java @@ -1,6 +1,10 @@ package org.raddatz.familienarchiv.dashboard; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditLogQueryRepository; +import org.raddatz.familienarchiv.audit.ContributorRow; +import org.raddatz.familienarchiv.audit.PulseStatsRow; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.config.FlywayConfig; import org.springframework.beans.factory.annotation.Autowired; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java index 23b19ec2..c62fdb8c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.TranscriptionBlock; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java index 7d678249..6c7a47ac 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java @@ -6,8 +6,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; -import org.raddatz.familienarchiv.dashboard.AuditLogQueryService; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; import org.raddatz.familienarchiv.repository.DocumentRepository; -- 2.49.1 From f197eb97528418450ef1798bbfa46c27d9a2c4c9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:00:20 +0200 Subject: [PATCH 30/49] feat(repository): add bulk completion stats query to TranscriptionBlockRepository Adds findCompletionStatsForDocuments() returning reviewed-block percentage per document in a single native SQL GROUP BY query. Needed for the new DocumentSearchItem DTO in issue #281. Co-Authored-By: Claude Sonnet 4.6 --- .../repository/CompletionStatsRow.java | 8 ++ .../TranscriptionBlockRepository.java | 12 ++ ...riptionBlockRepositoryIntegrationTest.java | 104 ++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/CompletionStatsRow.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryIntegrationTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/CompletionStatsRow.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/CompletionStatsRow.java new file mode 100644 index 00000000..f1680df5 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/CompletionStatsRow.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.repository; + +import java.util.UUID; + +public interface CompletionStatsRow { + UUID getDocumentId(); + int getCompletionPercentage(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java index c88830ad..1bf2d108 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java @@ -5,12 +5,24 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; public interface TranscriptionBlockRepository extends JpaRepository { + @Query(value = """ + SELECT + b.document_id AS documentId, + ROUND(COUNT(*) FILTER (WHERE b.reviewed = true) * 100.0 / COUNT(*))::int AS completionPercentage + FROM transcription_blocks b + WHERE b.document_id IN :documentIds + GROUP BY b.document_id + """, nativeQuery = true) + List findCompletionStatsForDocuments( + @Param("documentIds") Collection documentIds); + List findByDocumentIdOrderBySortOrderAsc(UUID documentId); Optional findByIdAndDocumentId(UUID id, UUID documentId); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryIntegrationTest.java new file mode 100644 index 00000000..dde0c089 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryIntegrationTest.java @@ -0,0 +1,104 @@ +package org.raddatz.familienarchiv.repository; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TranscriptionBlockRepositoryIntegrationTest { + + static final UUID DOC_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + static final UUID DOC_B = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + static final UUID ANN_A = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); + static final UUID ANN_B = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + @Autowired TranscriptionBlockRepository repository; + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')", + "INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, true)" + }) + void findCompletionStats_returns_100_when_all_blocks_reviewed() { + List rows = repository.findCompletionStatsForDocuments(List.of(DOC_A)); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_A); + assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(100); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')", + "INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)" + }) + void findCompletionStats_returns_0_when_no_blocks_reviewed() { + List rows = repository.findCompletionStatsForDocuments(List.of(DOC_A)); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(0); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')" + }) + void findCompletionStats_returns_empty_when_document_has_no_blocks() { + List rows = repository.findCompletionStatsForDocuments(List.of(DOC_A)); + + assertThat(rows).isEmpty(); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')", + "INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 2, false)", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 3, false)" + }) + void findCompletionStats_rounds_partial_completion_correctly() { + List rows = repository.findCompletionStatsForDocuments(List.of(DOC_A)); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(25); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Doc B', 'b.pdf', 'PLACEHOLDER')", + "INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')", + "INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 1, 0, 0, 1, 1, '#fff')", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)", + "INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 0, false)" + }) + void findCompletionStats_handles_multiple_documents_in_one_call() { + List rows = repository.findCompletionStatsForDocuments(List.of(DOC_A, DOC_B)); + + Map byDoc = rows.stream() + .collect(Collectors.toMap(CompletionStatsRow::getDocumentId, CompletionStatsRow::getCompletionPercentage)); + + assertThat(byDoc).containsEntry(DOC_A, 100); + assertThat(byDoc).containsEntry(DOC_B, 0); + } +} -- 2.49.1 From 16614d1bfb2dc8124c53a393679b641a5eb01b09 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:05:49 +0200 Subject: [PATCH 31/49] feat(audit): add findRecentContributorsForDocuments query (max 4, recency order) Adds a window-function query that returns at most 4 contributors per document ordered by most-recent activity. Used by DocumentService to populate the contributors field in DocumentSearchItem (issue #281). Co-Authored-By: Claude Sonnet 4.6 --- .../audit/AuditLogQueryRepository.java | 34 +++++++ ...ditLogQueryRepositoryContributorsTest.java | 94 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java index adc933d0..2d725c69 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -106,4 +106,38 @@ public interface AuditLogQueryRepository extends JpaRepository { ORDER BY a.document_id, MIN(a.happened_at) """, nativeQuery = true) List findContributorsPerDocument(@Param("documentIds") List documentIds); + + @Query(value = """ + SELECT + ranked.document_id AS documentId, + ranked.actorInitials AS actorInitials, + ranked.actorColor AS actorColor, + ranked.actorName AS actorName + FROM ( + SELECT + a.document_id, + CASE + WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL + THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1)) + WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1)) + WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1)) + ELSE '?' + END AS actorInitials, + COALESCE(u.color, '') AS actorColor, + NULLIF(CONCAT_WS(' ', u.first_name, u.last_name), '') AS actorName, + ROW_NUMBER() OVER ( + PARTITION BY a.document_id + ORDER BY MAX(a.happened_at) DESC + ) AS rn + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_id + WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED') + AND a.document_id IN :documentIds + AND a.actor_id IS NOT NULL + GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color + ) ranked + WHERE ranked.rn <= 4 + ORDER BY ranked.document_id, ranked.rn + """, nativeQuery = true) + List findRecentContributorsForDocuments(@Param("documentIds") List documentIds); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java new file mode 100644 index 00000000..1126b447 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryContributorsTest.java @@ -0,0 +1,94 @@ +package org.raddatz.familienarchiv.dashboard; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.audit.AuditLogQueryRepository; +import org.raddatz.familienarchiv.audit.ContributorRow; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class AuditLogQueryRepositoryContributorsTest { + + static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + static final UUID USER_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000001"); + static final UUID USER_B = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000002"); + static final UUID USER_C = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000003"); + static final UUID USER_D = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000004"); + static final UUID USER_E = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000005"); + + @Autowired AuditLogQueryRepository auditLogQueryRepository; + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#f00')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void findRecentContributors_returns_contributor_with_initials_and_color() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID)); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID); + assertThat(rows.get(0).getActorInitials()).isEqualTo("AM"); + assertThat(rows.get(0).getActorColor()).isEqualTo("#f00"); + } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#aaa')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000002', true, 'b@test.com', 'pw', 'Ben', 'Wolf', '#bbb')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000003', true, 'c@test.com', 'pw', 'Clara', 'Zorn', '#ccc')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000004', true, 'd@test.com', 'pw', 'Dirk', 'Ott', '#ddd')", + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000005', true, 'e@test.com', 'pw', 'Eva', 'Kern', '#eee')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '5 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '4 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000003', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '3 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000004', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '2 hours')", + "INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '1 hour')" + }) + void findRecentContributors_limits_to_4_most_recent() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID)); + + assertThat(rows).hasSize(4); + // Most recent first: E, D, C, B (A is 5th, excluded) + assertThat(rows.get(0).getActorInitials()).isEqualTo("EK"); + assertThat(rows.get(1).getActorInitials()).isEqualTo("DO"); + assertThat(rows.get(2).getActorInitials()).isEqualTo("CZ"); + assertThat(rows.get(3).getActorInitials()).isEqualTo("BW"); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')" + }) + void findRecentContributors_returns_empty_when_no_audit_entries() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID)); + + assertThat(rows).isEmpty(); + } + + @Test + @Sql(statements = { + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')", + // Deleted user: ON DELETE SET NULL makes actor_id NULL — query excludes these rows + "INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('TEXT_SAVED', NULL, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void findRecentContributors_excludes_entries_from_deleted_users() { + List rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID)); + + assertThat(rows).isEmpty(); + } +} -- 2.49.1 From ab3a026febc331f5d5dcf7a40e3f9490efa51e1d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:17:53 +0200 Subject: [PATCH 32/49] feat(dto): add DocumentSearchItem record and refactor DocumentSearchResult to items-based shape Replaces {documents, matchData, total} with {items: List, total} where each item collocates document + matchData + completionPercentage + contributors. Co-Authored-By: Claude Sonnet 4.6 --- .../dto/DocumentSearchItem.java | 18 +++++++ .../dto/DocumentSearchResult.java | 27 ++-------- .../dto/DocumentSearchResultTest.java | 53 ++++++++----------- 3 files changed, 45 insertions(+), 53 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchItem.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchItem.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchItem.java new file mode 100644 index 00000000..a2c62e0a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchItem.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.model.Document; + +import java.util.List; + +public record DocumentSearchItem( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Document document, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + SearchMatchData matchData, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + int completionPercentage, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List contributors +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java index 525dec84..764d9c12 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java @@ -1,35 +1,16 @@ package org.raddatz.familienarchiv.dto; import io.swagger.v3.oas.annotations.media.Schema; -import org.raddatz.familienarchiv.model.Document; import java.util.List; -import java.util.Map; -import java.util.UUID; public record DocumentSearchResult( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - List documents, + List items, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - long total, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - Map matchData + long total ) { - /** - * Creates a fully-enriched result from documents and their match overlay data. - * Absent map entries (e.g. document deleted between FTS and enrichment) are safe — - * the frontend treats a missing entry as "no match data". - */ - public static DocumentSearchResult withMatchData(List documents, Map matchData) { - return new DocumentSearchResult(documents, documents.size(), matchData); - } - - /** - * Creates a result without match data — used for filter-only searches (no text query). - * No pagination yet — the full matched set is always returned. - * When pagination is added, total must come from a DB COUNT query, not list.size(). - */ - public static DocumentSearchResult of(List documents) { - return withMatchData(documents, Map.of()); + public static DocumentSearchResult of(List items) { + return new DocumentSearchResult(items, items.size()); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dto/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dto/DocumentSearchResultTest.java index 36c50a23..3673459d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dto/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dto/DocumentSearchResultTest.java @@ -2,59 +2,52 @@ package org.raddatz.familienarchiv.dto; import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import java.util.List; -import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class DocumentSearchResultTest { - private Document doc(UUID id) { - return Document.builder() - .id(id) + private DocumentSearchItem item(UUID docId) { + Document doc = Document.builder() + .id(docId) .title("Test") .originalFilename("test.pdf") .status(DocumentStatus.UPLOADED) .build(); + return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of()); } @Test - void withMatchData_total_equals_list_size() { + void of_total_equals_list_size() { + DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID()))); + + assertThat(result.total()).isEqualTo(2L); + } + + @Test + void of_exposes_items_with_completion_and_contributors() { UUID id = UUID.randomUUID(); - List docs = List.of(doc(id)); - Map matchData = Map.of(id, SearchMatchData.empty()); + ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun"); + Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf") + .status(DocumentStatus.UPLOADED).build(); + DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor)); - DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData); + DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); - assertThat(result.total()).isEqualTo(1L); + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).completionPercentage()).isEqualTo(75); + assertThat(result.items().get(0).contributors()).containsExactly(actor); } @Test - void withMatchData_exposes_match_data_map() { - UUID id = UUID.randomUUID(); - SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); - DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data)); - - assertThat(result.matchData()).containsKey(id); - assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet"); - } - - @Test - void of_factory_returns_empty_match_data() { - UUID id = UUID.randomUUID(); - DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id))); - - assertThat(result.matchData()).isEmpty(); - assertThat(result.total()).isEqualTo(1L); - } - - @Test - void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException { - Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class); + void items_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException { + Schema schema = DocumentSearchResult.class.getDeclaredField("items").getAnnotation(Schema.class); assertThat(schema).isNotNull(); assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); } -- 2.49.1 From 8df0c3a1ef3af097ab8d9bff129e4249ec909d00 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:18:35 +0200 Subject: [PATCH 33/49] feat(service): assemble DocumentSearchItem in DocumentService with completion and contributors DocumentService.searchDocuments now fetches completion percentages and recent contributors per document and zips them into DocumentSearchItem records. Update affected tests to use the new items-based result shape. Co-Authored-By: Claude Sonnet 4.6 --- .../audit/AuditLogQueryService.java | 10 +++- .../service/DocumentService.java | 46 ++++++++++++++++--- .../controller/DocumentControllerTest.java | 16 ++++--- .../service/DocumentServiceSortTest.java | 12 +++-- .../service/DocumentServiceTest.java | 21 +++++---- 5 files changed, 78 insertions(+), 27 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java index da887b05..c007f4bb 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -38,7 +38,15 @@ public class AuditLogQueryService { public Map> findContributorsPerDocument(List documentIds) { if (documentIds.isEmpty()) return Map.of(); - List rows = queryRepository.findContributorsPerDocument(documentIds); + return toContributorMap(queryRepository.findContributorsPerDocument(documentIds)); + } + + public Map> findRecentContributorsPerDocument(List documentIds) { + if (documentIds.isEmpty()) return Map.of(); + return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds)); + } + + private Map> toContributorMap(List rows) { Map> result = new LinkedHashMap<>(); for (ContributorRow row : rows) { result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>()) 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 c27afdd9..c2f58389 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -3,8 +3,11 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; @@ -18,7 +21,9 @@ import org.raddatz.familienarchiv.model.ScriptType; import org.raddatz.familienarchiv.model.TrainingLabel; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; +import org.raddatz.familienarchiv.repository.CompletionStatsRow; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; @@ -59,6 +64,8 @@ public class DocumentService { private final DocumentVersionService documentVersionService; private final AnnotationService annotationService; private final AuditService auditService; + private final TranscriptionBlockRepository transcriptionBlockRepository; + private final AuditLogQueryService auditLogQueryService; public record StoreResult(Document document, boolean isNew) {} @@ -344,7 +351,7 @@ public class DocumentService { if (hasText) { rankedIds = documentRepository.findRankedIdsByFts(text); - if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of()); + if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); } boolean useOrLogic = tagOperator == TagOperator.OR; @@ -363,13 +370,11 @@ public class DocumentService { // generates an INNER JOIN that silently drops documents with null sender/receivers. if (sort == DocumentSort.RECEIVER) { List results = documentRepository.findAll(spec); - List sorted = sortByFirstReceiver(results, dir); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); + return buildResult(sortByFirstReceiver(results, dir), text); } if (sort == DocumentSort.SENDER) { List results = documentRepository.findAll(spec); - List sorted = sortBySender(results, dir); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); + return buildResult(sortBySender(results, dir), text); } // RELEVANCE: default when text present and no explicit sort given @@ -382,12 +387,39 @@ public class DocumentService { .sorted(Comparator.comparingInt( doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE))) .toList(); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); + return buildResult(sorted, text); } Sort springSort = resolveSort(sort, dir); List results = documentRepository.findAll(spec, springSort); - return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text)); + return buildResult(results, text); + } + + private DocumentSearchResult buildResult(List documents, String text) { + List colorResolved = resolveDocumentTagColors(documents); + Map matchData = enrichWithMatchData(colorResolved, text); + + List docIds = colorResolved.stream().map(Document::getId).toList(); + Map completionByDoc = fetchCompletionPercentages(docIds); + Map> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds); + + List items = colorResolved.stream().map(doc -> new DocumentSearchItem( + doc, + matchData.getOrDefault(doc.getId(), SearchMatchData.empty()), + completionByDoc.getOrDefault(doc.getId(), 0), + contributorsByDoc.getOrDefault(doc.getId(), List.of()) + )).toList(); + + return DocumentSearchResult.of(items); + } + + private Map fetchCompletionPercentages(List docIds) { + if (docIds.isEmpty()) return Map.of(); + Map result = new HashMap<>(); + for (CompletionStatsRow row : transcriptionBlockRepository.findCompletionStatsForDocuments(docIds)) { + result.put(row.getDocumentId(), row.getCompletionPercentage()); + } + return result; } private Sort resolveSort(DocumentSort sort, String dir) { 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 f8aaf5dd..6b8b5fbc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -25,10 +25,12 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.raddatz.familienarchiv.dto.DocumentSearchItem; +import org.raddatz.familienarchiv.dto.SearchMatchData; + import java.time.LocalDateTime; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -115,12 +117,12 @@ class DocumentControllerTest { mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(0)) - .andExpect(jsonPath("$.documents").isArray()); + .andExpect(jsonPath("$.items").isArray()); } @Test @WithMockUser - void search_responseBodyContainsMatchDataKey() throws Exception { + void search_responseBodyItemsContainMatchData() throws Exception { UUID docId = UUID.randomUUID(); Document doc = Document.builder() .id(docId) @@ -128,15 +130,15 @@ class DocumentControllerTest { .originalFilename("brief.pdf") .status(DocumentStatus.UPLOADED) .build(); - var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData( + var matchData = new SearchMatchData( "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) - .thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData))); + .thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of())))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.matchData").isMap()) - .andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet") + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet") .value("Er schrieb einen langen Brief")); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java index 49726999..2deee90f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java @@ -5,11 +5,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; @@ -30,6 +32,8 @@ class DocumentServiceSortTest { @Mock TagService tagService; @Mock DocumentVersionService documentVersionService; @Mock AnnotationService annotationService; + @Mock AuditLogQueryService auditLogQueryService; + @Mock TranscriptionBlockRepository transcriptionBlockRepository; @InjectMocks DocumentService documentService; // ─── searchDocuments — DATE sort ────────────────────────────────────────── @@ -56,8 +60,8 @@ class DocumentServiceSortTest { "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null); // Expect: date order (newer 1960 first), NOT rank order (older 1940 first) - assertThat(result.documents()).hasSize(2); - assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first } // ─── searchDocuments — RELEVANCE sort ───────────────────────────────────── @@ -78,7 +82,7 @@ class DocumentServiceSortTest { "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); // Expect: rank order restored (id1 first) - assertThat(result.documents().get(0).getId()).isEqualTo(id1); + assertThat(result.items().get(0).document().getId()).isEqualTo(id1); } @Test @@ -96,6 +100,6 @@ class DocumentServiceSortTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, null, null, null); - assertThat(result.documents().get(0).getId()).isEqualTo(id1); + assertThat(result.items().get(0).document().getId()).isEqualTo(id1); } } 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 dc23df1a..396a7362 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -7,7 +7,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; @@ -20,6 +22,7 @@ import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -51,6 +54,8 @@ class DocumentServiceTest { @Mock DocumentVersionService documentVersionService; @Mock AnnotationService annotationService; @Mock AuditService auditService; + @Mock AuditLogQueryService auditLogQueryService; + @Mock TranscriptionBlockRepository transcriptionBlockRepository; @InjectMocks DocumentService documentService; // ─── deleteDocument ─────────────────────────────────────────────────────── @@ -1298,8 +1303,8 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); - assertThat(result.documents()).hasSize(2); - assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender"); + assertThat(result.items()).hasSize(2); + assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender"); } // ─── searchDocuments — RECEIVER sort, empty receivers ─────────────────────── @@ -1318,7 +1323,7 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null); - assertThat(result.documents()).extracting(Document::getTitle) + assertThat(result.items()).extracting(item -> item.document().getTitle()) .containsExactly("Has Receiver", "No Receivers"); } @@ -1341,7 +1346,7 @@ class DocumentServiceTest { null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); // null lastName should sort to end (treated as empty), not before "smith" (as "null") - assertThat(result.documents()).extracting(Document::getTitle) + assertThat(result.items()).extracting(item -> item.document().getTitle()) .containsExactly("smith doc", "Null lastname doc"); } @@ -1362,8 +1367,8 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); - assertThat(result.matchData()).containsKey(docId); - SearchMatchData md = result.matchData().get(docId); + assertThat(result.items()).hasSize(1); + SearchMatchData md = result.items().get(0).matchData(); assertThat(md.titleOffsets()).hasSize(1); assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0 } @@ -1376,7 +1381,7 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, null, null, null); - assertThat(result.matchData()).isEmpty(); + assertThat(result.items()).isEmpty(); } @Test @@ -1395,7 +1400,7 @@ class DocumentServiceTest { DocumentSearchResult result = documentService.searchDocuments( "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); - SearchMatchData md = result.matchData().get(docId); + SearchMatchData md = result.items().get(0).matchData(); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13 } -- 2.49.1 From 71c02626f4546a02bdaa1c1dde59a9011dc1cdcb Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:19:24 +0200 Subject: [PATCH 34/49] feat(migration): V48 add composite index on transcription_blocks(document_id, reviewed) Speeds up the bulk completion percentage query added in previous commit. Co-Authored-By: Claude Sonnet 4.6 --- .../V48__add_index_transcription_blocks_document_reviewed.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V48__add_index_transcription_blocks_document_reviewed.sql diff --git a/backend/src/main/resources/db/migration/V48__add_index_transcription_blocks_document_reviewed.sql b/backend/src/main/resources/db/migration/V48__add_index_transcription_blocks_document_reviewed.sql new file mode 100644 index 00000000..ff495630 --- /dev/null +++ b/backend/src/main/resources/db/migration/V48__add_index_transcription_blocks_document_reviewed.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_reviewed + ON transcription_blocks (document_id, reviewed); -- 2.49.1 From 05d434fed3bd929de84dfd246588d99e3826a8da Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:23:08 +0200 Subject: [PATCH 35/49] chore(frontend): regenerate API types with DocumentSearchItem and updated DocumentSearchResult Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index f3ac663b..11c323f9 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1951,13 +1951,17 @@ export interface components { /** Format: int32 */ totalPages?: number; }; + DocumentSearchItem: { + document: components["schemas"]["Document"]; + matchData: components["schemas"]["SearchMatchData"]; + /** Format: int32 */ + completionPercentage: number; + contributors: components["schemas"]["ActivityActorDTO"][]; + }; DocumentSearchResult: { - documents: components["schemas"]["Document"][]; + items: components["schemas"]["DocumentSearchItem"][]; /** Format: int64 */ total: number; - matchData: { - [key: string]: components["schemas"]["SearchMatchData"]; - }; }; MatchOffset: { /** Format: int32 */ @@ -4451,7 +4455,3 @@ export interface operations { }; }; } - -export type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; -export type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; -export type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; -- 2.49.1 From eeca30e7a6b696bc50ea63df2088a10e71d49ca4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 23:24:46 +0200 Subject: [PATCH 36/49] feat(frontend): add --header-height, bump initials to text-[10px], update nav to /documents Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/ContributorStack.svelte | 4 ++-- frontend/src/routes/AppNav.svelte | 8 ++++---- frontend/src/routes/layout.css | 3 +++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/ContributorStack.svelte b/frontend/src/lib/components/ContributorStack.svelte index fc627627..4e417fc2 100644 --- a/frontend/src/lib/components/ContributorStack.svelte +++ b/frontend/src/lib/components/ContributorStack.svelte @@ -24,7 +24,7 @@ const safeContributors = $derived(contributors ?? []); @@ -33,7 +33,7 @@ const safeContributors = $derived(contributors ?? []); {/each} {#if hasMore} diff --git a/frontend/src/routes/AppNav.svelte b/frontend/src/routes/AppNav.svelte index 026b7f72..29eea77b 100644 --- a/frontend/src/routes/AppNav.svelte +++ b/frontend/src/routes/AppNav.svelte @@ -40,9 +40,9 @@ function handleOverlayKeydown(event: KeyboardEvent) {